Compare commits

..

1 Commits

Author SHA1 Message Date
Vincent Koc
54e5105c12 test(plugin-sdk): refresh callable export budget expectation 2026-06-22 19:00:17 +08:00
735 changed files with 5805 additions and 20590 deletions

View File

@@ -1,76 +0,0 @@
name: Docs bug report
description: Report documentation defects (incorrect, missing, outdated, or contradictory docs).
title: "[Docs Bug]: "
labels:
- bug
- docs
body:
- type: markdown
attributes:
value: |
Report a documentation defect with concrete evidence from current docs behavior/content.
Please only report one documentation defect per submission.
- type: textarea
id: summary
attributes:
label: Summary
description: One-sentence statement of what is wrong in the docs.
placeholder: The WhatsApp config example defines duplicate top-level keys in one JSON5 block.
validations:
required: true
- type: input
id: doc_paths
attributes:
label: Affected docs path(s) or URL(s)
description: Repo-relative docs file path(s) or published docs URL(s).
placeholder: docs/gateway/config-channels.md or https://docs.openclaw.ai/gateway/config-channels
validations:
required: true
- type: textarea
id: repro
attributes:
label: Steps to reproduce / verify
description: Minimal steps to observe the docs defect in the current docs.
placeholder: |
1. Open docs/gateway/config-channels.md
2. Go to the WhatsApp example block
3. Observe duplicate top-level key definitions
validations:
required: true
- type: textarea
id: expected
attributes:
label: Expected docs behavior/content
description: What the docs should say/show instead.
placeholder: The example should use a single merged top-level object with no duplicate keys.
validations:
required: true
- type: textarea
id: actual
attributes:
label: Actual docs behavior/content
description: What the docs currently say/show.
placeholder: The snippet defines the same top-level key twice in one object.
validations:
required: true
- type: textarea
id: impact
attributes:
label: Impact
description: Who is affected and practical consequence.
placeholder: Users who copy-paste the snippet can end up with ambiguous config behavior.
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Evidence
description: Links/snippets/screenshots proving the docs defect.
placeholder: Include exact file links and line ranges.
validations:
required: true
- type: textarea
id: additional_information
attributes:
label: Additional information
description: Optional context, related issues/PRs, or constraints.

View File

@@ -305,7 +305,6 @@ jobs:
shard_name: shard.shardName,
groups: shard.groups,
configs: shard.configs,
env: shard.env,
includePatterns: shard.includePatterns,
requires_dist: shard.requiresDist,
runner: shard.runner,
@@ -1238,7 +1237,6 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
OPENCLAW_NODE_TEST_GROUPS_JSON: ${{ toJson(matrix.groups || null) }}
OPENCLAW_NODE_TEST_CONFIGS_JSON: ${{ toJson(matrix.configs) }}
OPENCLAW_NODE_TEST_ENV_JSON: ${{ toJson(matrix.env) }}
OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON: ${{ toJson(matrix.includePatterns) }}
OPENCLAW_VITEST_SHARD_NAME: ${{ matrix.shard_name }}
OPENCLAW_VITEST_NO_OUTPUT_TIMEOUT_MS: "300000"
@@ -1257,7 +1255,6 @@ jobs:
? groups
: [{
configs: JSON.parse(process.env.OPENCLAW_NODE_TEST_CONFIGS_JSON ?? "[]"),
env: JSON.parse(process.env.OPENCLAW_NODE_TEST_ENV_JSON ?? "null"),
includePatterns: JSON.parse(
process.env.OPENCLAW_NODE_TEST_INCLUDE_PATTERNS_JSON ?? "null",
),
@@ -1273,13 +1270,6 @@ jobs:
...process.env,
...(plan.shard_name ? { OPENCLAW_VITEST_SHARD_NAME: plan.shard_name } : {}),
};
if (plan.env && typeof plan.env === "object" && !Array.isArray(plan.env)) {
for (const [key, value] of Object.entries(plan.env)) {
if (typeof value === "string") {
childEnv[key] = value;
}
}
}
if (Array.isArray(plan.includePatterns) && plan.includePatterns.length > 0) {
const includeFile = join(
process.env.RUNNER_TEMP ?? ".",

View File

@@ -24,9 +24,7 @@ jobs:
steps:
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
# Old PR events can carry a stale base SHA that predates current
# trusted checker scripts. Use the workflow revision instead.
ref: ${{ github.workflow_sha }}
ref: ${{ github.event.pull_request.base.sha }}
persist-credentials: false
- uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
id: app-token

View File

@@ -1,313 +0,0 @@
name: Release QA Profile Evidence
run-name: ${{ format('Release QA Profile Evidence {0}', inputs.ref) }}
on:
workflow_dispatch:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
default: main
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
workflow_call:
inputs:
ref:
description: OpenClaw branch, tag, or SHA to run
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
outputs:
artifact_name:
description: Uploaded release QA profile evidence artifact name
value: ${{ jobs.run_release_profile.outputs.artifact_name }}
target_sha:
description: Resolved OpenClaw SHA that produced the evidence
value: ${{ jobs.run_release_profile.outputs.target_sha }}
trusted_reason:
description: Trust reason accepted before the secret-bearing QA job
value: ${{ jobs.run_release_profile.outputs.trusted_reason }}
qa_evidence_path:
description: Path to qa-evidence.json inside the uploaded artifact
value: ${{ jobs.run_release_profile.outputs.qa_evidence_path }}
permissions:
contents: read
concurrency:
group: release-qa-profile-evidence-${{ inputs.expected_sha || inputs.ref }}
cancel-in-progress: false
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
NODE_VERSION: "24.x"
OPENCLAW_BUILD_PRIVATE_QA: "1"
OPENCLAW_ENABLE_PRIVATE_QA_CLI: "1"
OPENCLAW_QA_REDACT_PUBLIC_METADATA: "1"
OPENCLAW_QA_TRANSPORT_READY_TIMEOUT_MS: "180000"
jobs:
authorize_actor:
name: Authorize workflow actor
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
authorized: ${{ steps.permission.outputs.authorized }}
steps:
- name: Require maintainer-level repository access
id: permission
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
with:
script: |
if (context.eventName !== "workflow_dispatch") {
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
core.setOutput("authorized", "true");
return;
}
const allowed = new Set(["admin", "maintain", "write"]);
const { owner, repo } = context.repo;
const { data } = await github.rest.repos.getCollaboratorPermissionLevel({
owner,
repo,
username: context.actor,
});
const permission = data.permission;
core.info(`Actor ${context.actor} permission: ${permission}`);
if (!allowed.has(permission)) {
core.notice(
`Workflow requires write/maintain/admin access. Actor "${context.actor}" has "${permission}".`,
);
core.setOutput("authorized", "false");
return;
}
core.setOutput("authorized", "true");
validate_selected_ref:
name: Validate selected ref
needs: authorize_actor
if: needs.authorize_actor.outputs.authorized == 'true'
runs-on: blacksmith-8vcpu-ubuntu-2404
outputs:
selected_revision: ${{ steps.validate.outputs.selected_revision }}
trusted_reason: ${{ steps.validate.outputs.trusted_reason }}
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ inputs.ref }}
fetch-depth: 0
- name: Validate selected ref
id: validate
env:
EXPECTED_SHA: ${{ inputs.expected_sha }}
INPUT_REF: ${{ inputs.ref }}
shell: bash
run: |
set -euo pipefail
selected_revision="$(git rev-parse HEAD)"
expected_sha="${EXPECTED_SHA,,}"
trusted_reason=""
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
exit 1
fi
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
exit 1
fi
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
trusted_reason="main-ancestor"
elif git tag --points-at "$selected_revision" | grep -Eq '^v'; then
trusted_reason="release-tag"
elif [[ "$INPUT_REF" =~ ^release/[0-9]{4}\.[0-9]+\.[0-9]+$ ]]; then
git fetch --no-tags origin "+refs/heads/${INPUT_REF}:refs/remotes/origin/${INPUT_REF}"
release_branch_sha="$(git rev-parse "refs/remotes/origin/${INPUT_REF}")"
if [[ "$selected_revision" == "$release_branch_sha" ]]; then
trusted_reason="release-branch-head"
fi
fi
if [[ -z "$trusted_reason" ]]; then
echo "Ref '${INPUT_REF}' resolved to $selected_revision, which is not trusted for this secret-bearing QA evidence run." >&2
echo "Allowed refs must be on main, point to a release tag, or match a release branch head." >&2
exit 1
fi
echo "selected_revision=$selected_revision" >> "$GITHUB_OUTPUT"
echo "trusted_reason=$trusted_reason" >> "$GITHUB_OUTPUT"
{
echo "### Target"
echo
echo "- Requested ref: \`${INPUT_REF}\`"
echo "- Resolved SHA: \`$selected_revision\`"
echo "- Trust reason: \`$trusted_reason\`"
} >> "$GITHUB_STEP_SUMMARY"
run_release_profile:
name: Generate release QA profile evidence
needs: validate_selected_ref
runs-on: blacksmith-8vcpu-ubuntu-2404
timeout-minutes: 60
permissions:
contents: read
outputs:
artifact_name: ${{ steps.evidence.outputs.artifact_name }}
target_sha: ${{ steps.evidence.outputs.target_sha }}
trusted_reason: ${{ steps.evidence.outputs.trusted_reason }}
qa_evidence_path: ${{ steps.evidence.outputs.qa_evidence_path }}
environment: qa-live-shared
steps:
- name: Checkout selected ref
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
with:
persist-credentials: false
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
fetch-depth: 1
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
node-version: ${{ env.NODE_VERSION }}
install-bun: "true"
- name: Validate required QA credential env
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing required OPENAI_API_KEY." >&2
exit 1
fi
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: node scripts/build-all.mjs qaRuntime
- name: Run release QA profile
id: run_profile
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
shell: bash
run: |
set -euo pipefail
output_dir=".artifacts/qa-e2e/release-profile-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}"
echo "output_dir=${output_dir}" >> "$GITHUB_OUTPUT"
qa_exit_code=0
pnpm openclaw qa run \
--repo-root . \
--qa-profile release \
--output-dir "${output_dir}" || qa_exit_code=$?
echo "qa_exit_code=${qa_exit_code}" >> "$GITHUB_OUTPUT"
- name: Validate release QA evidence
id: evidence
if: always()
env:
ARTIFACT_NAME: release-qa-profile-evidence-${{ needs.validate_selected_ref.outputs.selected_revision }}
OUTPUT_DIR: ${{ steps.run_profile.outputs.output_dir }}
REQUESTED_REF: ${{ inputs.ref }}
TARGET_SHA: ${{ needs.validate_selected_ref.outputs.selected_revision }}
TRUSTED_REASON: ${{ needs.validate_selected_ref.outputs.trusted_reason }}
shell: bash
run: |
set -euo pipefail
node --input-type=module <<'NODE'
import fs from "node:fs";
import path from "node:path";
const outputDir = process.env.OUTPUT_DIR;
if (!outputDir) {
throw new Error("OUTPUT_DIR is required");
}
const evidencePath = path.join(outputDir, "qa-evidence.json");
const payload = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
if (payload.profile !== "release") {
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(payload.profile)}`);
}
if (!payload.scorecard || !Array.isArray(payload.scorecard.categoryReports)) {
throw new Error("release qa-evidence.json must include scorecard.categoryReports");
}
if (payload.scorecard.categoryReports.length === 0) {
throw new Error("release qa-evidence.json scorecard has no category reports");
}
const manifest = {
artifactName: process.env.ARTIFACT_NAME,
generatedAt: new Date().toISOString(),
requestedRef: process.env.REQUESTED_REF,
targetSha: process.env.TARGET_SHA,
trustedReason: process.env.TRUSTED_REASON,
evidenceMode: payload.evidenceMode,
qaEvidencePath: "qa-evidence.json",
scorecard: {
categories: payload.scorecard.categories,
features: payload.scorecard.features,
categoryReports: payload.scorecard.categoryReports.length,
},
};
fs.writeFileSync(
path.join(outputDir, "release-qa-profile-evidence-manifest.json"),
`${JSON.stringify(manifest, null, 2)}\n`,
);
NODE
echo "artifact_name=${ARTIFACT_NAME}" >> "$GITHUB_OUTPUT"
echo "target_sha=${TARGET_SHA}" >> "$GITHUB_OUTPUT"
echo "trusted_reason=${TRUSTED_REASON}" >> "$GITHUB_OUTPUT"
echo "qa_evidence_path=qa-evidence.json" >> "$GITHUB_OUTPUT"
{
echo "### Release QA profile evidence"
echo
echo "- Artifact: \`${ARTIFACT_NAME}\`"
echo "- Target SHA: \`${TARGET_SHA}\`"
echo "- Evidence path: \`${OUTPUT_DIR}/qa-evidence.json\`"
echo "- Manifest: \`${OUTPUT_DIR}/release-qa-profile-evidence-manifest.json\`"
} >> "$GITHUB_STEP_SUMMARY"
- name: Upload release QA profile evidence
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-qa-profile-evidence-${{ needs.validate_selected_ref.outputs.selected_revision }}
path: ${{ steps.run_profile.outputs.output_dir }}
retention-days: 30
if-no-files-found: error
- name: Fail if release QA profile failed
if: always()
env:
QA_EXIT_CODE: ${{ steps.run_profile.outputs.qa_exit_code }}
shell: bash
run: |
set -euo pipefail
if [[ -z "${QA_EXIT_CODE:-}" ]]; then
echo "Release QA profile did not report an exit code." >&2
exit 1
fi
if [[ "$QA_EXIT_CODE" != "0" ]]; then
echo "Release QA profile failed with exit code ${QA_EXIT_CODE}." >&2
exit "$QA_EXIT_CODE"
fi

42
.github/workflows/tui-pty.yml vendored Normal file
View File

@@ -0,0 +1,42 @@
name: TUI PTY
on:
pull_request:
paths:
- "src/tui/**"
- "scripts/dev/tui-pty-test-watch.ts"
- "scripts/test-projects.test-support.mjs"
- "package.json"
- "pnpm-lock.yaml"
- "test/scripts/test-projects.test.ts"
- "test/vitest/vitest.test-shards.mjs"
- "test/vitest/vitest.tui-pty.config.ts"
- ".github/workflows/tui-pty.yml"
workflow_dispatch:
permissions:
contents: read
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
env:
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
jobs:
tui-pty:
runs-on: ubuntu-24.04
timeout-minutes: 8
env:
OPENCLAW_TUI_PTY_INCLUDE_LOCAL: "1"
steps:
- name: Checkout
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
- name: Setup Node environment
uses: ./.github/actions/setup-node-env
with:
install-bun: "false"
- name: Run TUI PTY tests
run: timeout --kill-after=30s 240s node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts

View File

@@ -297,10 +297,6 @@ jobs:
if: ${{ always() && !cancelled() && inputs.require_wsl2 }}
run: |
if ($env:OPENCLAW_WSL2_PROBE_OK -ne "true") {
if ($env:OPENCLAW_WSL2_RESTART_REQUIRED -eq "true") {
Write-Error "WSL2 probe enabled required Windows features, but the runner needs a reboot before WSL2 can start."
exit 1
}
Write-Error "WSL2 probe failed or WSL2 is unavailable on this Windows runner."
exit 1
}

View File

@@ -8,7 +8,6 @@ Skills own workflows; root owns hard policy and routing.
- Repo: `https://github.com/openclaw/openclaw`
- Replies: repo-root refs only: `extensions/telegram/src/index.ts:80`. No absolute paths, no `~/`.
- Docs/user-visible work: `pnpm docs:list`, then read relevant docs only.
- Existing-solutions preflight: before proposing or building a custom system, feature, workflow, tool, integration, or automation, do a lightweight check for open-source projects, maintained libraries, existing OpenClaw plugins, or free platforms that already solve it well enough. Prefer those when adequate. Build custom only when existing options are unsuitable, too expensive, unmaintained, unsafe, non-compliant, or the user explicitly asks for custom. Avoid paid-service recommendations unless the user explicitly approves spend. Keep this to a brief preflight gate, not a broad research assignment.
- Fix/triage answers need source, tests, current/shipped behavior, and dependency contract proof.
- Reviews/answers: high confidence required. Default to exhaustive relevant codebase search/read, including owners, callers, siblings, tests, docs, and upstream/dependency contracts before verdict. Diff-only review is insufficient.
- Review default: read the whole changed function/module plus callers, callees, sibling implementations, adjacent tests, scoped docs, and dependency/Codex contracts before saying `good`, `bad`, `best fix`, `proof sufficient`, or posting a comment. If challenged, keep reading first; do not defend the earlier verdict until the missing path is checked.

View File

@@ -14,7 +14,6 @@ import ai.openclaw.app.gateway.GatewayTlsProbeFailure
import ai.openclaw.app.gateway.GatewayTlsProbeResult
import ai.openclaw.app.gateway.GatewayUpdateAvailableSummary
import ai.openclaw.app.gateway.normalizeGatewayTlsFingerprint
import ai.openclaw.app.gateway.parseChatSendAck
import ai.openclaw.app.gateway.probeGatewayTlsFingerprint
import ai.openclaw.app.node.A2UIHandler
import ai.openclaw.app.node.CalendarHandler
@@ -633,11 +632,7 @@ class NodeRuntime(
put("idempotencyKey", JsonPrimitive(idempotencyKey))
}
val response = operatorSession.request("chat.send", params.toString())
val ack = parseChatSendAck(json, response)
ack.copy(runId = ack.runId ?: idempotencyKey)
},
refreshAfterTerminalSuccess = {
chat.refresh()
parseChatSendRunId(response) ?: idempotencyKey
},
speakAssistantReply = { text ->
// Voice-tab replies should speak through the dedicated reply speaker.
@@ -1848,6 +1843,15 @@ class NodeRuntime(
}
}
private fun parseChatSendRunId(response: String): String? {
return try {
val root = json.parseToJsonElement(response).asObjectOrNull() ?: return null
root["runId"].asStringOrNull()
} catch (_: Throwable) {
null
}
}
private fun parseTalkSessionId(response: String): String {
val root = json.parseToJsonElement(response).asObjectOrNull()
val sessionId =
@@ -2765,6 +2769,11 @@ internal fun resolveOperatorSessionConnectAuth(
)
}
internal fun shouldConnectOperatorSession(
auth: NodeRuntime.GatewayConnectAuth,
storedOperatorToken: String?,
): Boolean = resolveOperatorSessionConnectAuth(auth, storedOperatorToken) != null
private enum class HomeCanvasGatewayState {
Connected,
Connecting,

View File

@@ -393,6 +393,12 @@ class SecurePrefs(
return stored?.takeIf { it.isNotEmpty() }
}
/** Saves the paired gateway token under the current Android instance id. */
fun saveGatewayToken(token: String) {
val key = "gateway.token.${_instanceId.value}"
securePrefs.edit { putString(key, token.trim()) }
}
/** Loads the bootstrap token used during gateway setup and device-token handoff. */
fun loadGatewayBootstrapToken(): String? {
val key = "gateway.bootstrapToken.${_instanceId.value}"

View File

@@ -6,6 +6,14 @@ internal fun normalizeMainKey(raw: String?): String {
return if (!trimmed.isNullOrEmpty()) trimmed else "main"
}
/** Accepts only gateway session keys that can represent the main chat stream. */
internal fun isCanonicalMainSessionKey(raw: String?): Boolean {
val trimmed = raw?.trim().orEmpty()
if (trimmed.isEmpty()) return false
if (trimmed == "global") return true
return trimmed.startsWith("agent:")
}
/** Extracts the agent id from canonical agent-scoped main session keys. */
internal fun resolveAgentIdFromMainSessionKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()

View File

@@ -1,7 +1,6 @@
package ai.openclaw.app.chat
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.parseChatSendAck
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
@@ -20,21 +19,11 @@ import java.util.UUID
import java.util.concurrent.ConcurrentHashMap
import java.util.concurrent.atomic.AtomicLong
class ChatController internal constructor(
class ChatController(
private val scope: CoroutineScope,
private val session: GatewaySession,
private val json: Json,
private val requestGateway: suspend (method: String, paramsJson: String?) -> String,
) {
constructor(
scope: CoroutineScope,
session: GatewaySession,
json: Json,
) : this(
scope = scope,
json = json,
requestGateway = { method, paramsJson -> session.request(method, paramsJson) },
)
private var appliedMainSessionKey = "main"
private val _sessionKey = MutableStateFlow("main")
val sessionKey: StateFlow<String> = _sessionKey.asStateFlow()
@@ -278,9 +267,8 @@ class ChatController internal constructor(
)
}
}
val res = requestGateway("chat.send", params.toString())
val ack = parseChatSendAck(json, res)
val actualRunId = ack.runId ?: runId
val res = session.request("chat.send", params.toString())
val actualRunId = parseRunId(res) ?: runId
if (actualRunId != runId) {
// Gateway may return a canonical run id; move all pending bookkeeping to that id.
optimisticMessagesByRunId[actualRunId] = optimisticMessagesByRunId.remove(runId) ?: optimisticMessage
@@ -291,24 +279,7 @@ class ChatController internal constructor(
_pendingRunCount.value = pendingRuns.size
}
}
if (ack.isTerminal) {
clearPendingRun(actualRunId)
removeOptimisticMessage(actualRunId)
pendingToolCallsById.clear()
publishPendingToolCalls()
_streamingAssistantText.value = null
if (ack.isTerminalSuccess) {
refreshCurrentHistoryBestEffort()
true
} else {
// Terminal timeout/error means the gateway did not accept a runnable turn.
// Surface failed acceptance instead of letting a cleared composer look successful.
_errorText.value = "Chat failed before the run started; try again."
false
}
} else {
true
}
true
} catch (err: Throwable) {
clearPendingRun(runId)
removeOptimisticMessage(runId)
@@ -332,7 +303,7 @@ class ChatController internal constructor(
put("sessionKey", JsonPrimitive(_sessionKey.value))
put("runId", JsonPrimitive(runId))
}
requestGateway("chat.abort", params.toString())
session.request("chat.abort", params.toString())
} catch (_: Throwable) {
// best-effort
}
@@ -385,7 +356,7 @@ class ChatController internal constructor(
) {
try {
val historyJson =
requestGateway(
session.request(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(sessionKey)) }.toString(),
)
@@ -420,7 +391,7 @@ class ChatController internal constructor(
put("includeUnknown", JsonPrimitive(false))
if (limit != null && limit > 0) put("limit", JsonPrimitive(limit))
}
val res = requestGateway("sessions.list", params.toString())
val res = session.request("sessions.list", params.toString())
_sessions.value = parseSessions(res)
} catch (_: Throwable) {
// best-effort
@@ -437,7 +408,7 @@ class ChatController internal constructor(
if (!force && last != null && now - last < 10_000) return
lastHealthPollAtMs = now
try {
requestGateway("health", null)
session.request("health", null)
_healthOk.value = true
} catch (_: Throwable) {
_healthOk.value = false
@@ -480,7 +451,7 @@ class ChatController internal constructor(
val currentSessionKey = _sessionKey.value
val currentGeneration = historyLoadGeneration.get()
val historyJson =
requestGateway(
session.request(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
)
@@ -661,45 +632,6 @@ class ChatController internal constructor(
optimisticMessagesByRunId.entries.removeAll { entry -> entry.value !in retained }
}
private fun refreshCurrentHistoryBestEffort() {
scope.launch {
try {
val currentSessionKey = _sessionKey.value
val currentGeneration = historyLoadGeneration.get()
val historyJson =
requestGateway(
"chat.history",
buildJsonObject { put("sessionKey", JsonPrimitive(currentSessionKey)) }.toString(),
)
if (
!isCurrentHistoryLoad(
currentSessionKey,
_sessionKey.value,
currentGeneration,
historyLoadGeneration.get(),
)
) {
return@launch
}
val history =
parseHistory(
historyJson,
sessionKey = currentSessionKey,
previousMessages = _messages.value,
)
prunePersistedOptimisticMessages(history.messages)
_messages.value = mergeOptimisticMessages(incoming = history.messages, optimistic = optimisticMessagesByRunId.values)
_sessionId.value = history.sessionId
history.thinkingLevel
?.trim()
?.takeIf { it.isNotEmpty() }
?.let { _thinkingLevel.value = it }
} catch (_: Throwable) {
// best-effort
}
}
}
private fun parseHistory(
historyJson: String,
sessionKey: String,
@@ -796,6 +728,17 @@ class ChatController internal constructor(
_sessions.value = _sessions.value.filterNot { it.key == key }
}
private fun parseRunId(resJson: String): String? =
try {
json
.parseToJsonElement(resJson)
.asObjectOrNull()
?.get("runId")
.asStringOrNull()
} catch (_: Throwable) {
null
}
private fun normalizeThinking(raw: String): String =
when (raw.trim().lowercase()) {
"low" -> "low"

View File

@@ -1,46 +0,0 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
internal data class ChatSendAck(
val runId: String?,
val status: String?,
) {
val normalizedStatus: String
get() = status?.trim()?.lowercase().orEmpty()
val isTerminalSuccess: Boolean
get() = normalizedStatus == "ok"
val isTerminalFailure: Boolean
get() = normalizedStatus == "timeout" || normalizedStatus == "error"
val isTerminal: Boolean
get() = isTerminalSuccess || isTerminalFailure
}
internal fun chatSendAckHistorySinceSeconds(
ack: ChatSendAck,
startedAtSeconds: Double,
): Double? = if (ack.isTerminalSuccess) null else startedAtSeconds
internal fun parseChatSendAck(
json: Json,
responseJson: String,
): ChatSendAck =
try {
val obj = json.parseToJsonElement(responseJson).asObjectOrNull()
ChatSendAck(
runId = obj?.get("runId").asStringOrNull(),
status = obj?.get("status").asStringOrNull(),
)
} catch (_: Throwable) {
ChatSendAck(runId = null, status = null)
}
private fun JsonElement?.asObjectOrNull(): JsonObject? = this as? JsonObject
private fun JsonElement?.asStringOrNull(): String? = (this as? JsonPrimitive)?.takeIf { it.isString }?.content

View File

@@ -166,6 +166,14 @@ class GatewayDiscovery(
}
}
private fun stopLocalDiscovery() {
try {
nsd.stopServiceDiscovery(discoveryListener)
} catch (_: Throwable) {
// ignore (best-effort)
}
}
private fun startUnicastDiscovery(domain: String) {
unicastJob =
scope.launch(Dispatchers.IO) {

View File

@@ -260,6 +260,24 @@ class GatewaySession(
currentConnection?.closeQuietly()
}
fun currentCanvasHostUrl(): String? = pluginSurfaceUrls["canvas"]
/** Refreshes the canvas plugin surface URL and caches the normalized Android-reachable URL. */
suspend fun refreshCanvasHostUrl(timeoutMs: Long = 8_000): String? {
val refreshed =
refreshPluginSurfaceUrl(
method = "node.pluginSurface.refresh",
params = buildJsonObject { put("surface", JsonPrimitive("canvas")) },
timeoutMs = timeoutMs,
)
if (!refreshed.isNullOrBlank()) {
pluginSurfaceUrls = pluginSurfaceUrls + ("canvas" to refreshed)
}
return refreshed
}
fun currentMainSessionKey(): String? = mainSessionKey
/** Sends a best-effort node.event and returns false instead of throwing on failure. */
suspend fun sendNodeEvent(
event: String,
@@ -279,6 +297,28 @@ class GatewaySession(
}
}
private suspend fun refreshPluginSurfaceUrl(
method: String,
params: JsonElement?,
timeoutMs: Long,
): String? {
val conn = currentConnection ?: return null
return try {
val res = conn.request(method, params, timeoutMs)
if (!res.ok) return null
val obj = res.payloadJson?.let { json.parseToJsonElement(it).asObjectOrNull() } ?: return null
val raw =
obj["pluginSurfaceUrls"]
.asObjectOrNull()
?.get("canvas")
.asStringOrNull()
normalizeCanvasHostUrl(raw, conn.endpoint, isTlsConnection = conn.tls != null)
} catch (err: Throwable) {
Log.d("OpenClawGateway", "$method failed: ${err.message ?: err::class.java.simpleName}")
null
}
}
/** Sends node.event and preserves the gateway RPC error shape for callers that need diagnostics. */
suspend fun sendNodeEventDetailed(
event: String,

View File

@@ -97,6 +97,8 @@ class CanvasController {
fun currentUrl(): String? = url
fun isDefaultCanvas(): Boolean = url == null
fun setDebugStatusEnabled(enabled: Boolean) {
debugStatusEnabled = enabled
applyDebugStatus()
@@ -203,6 +205,24 @@ class CanvasController {
}
}
suspend fun snapshotPngBase64(maxWidth: Int?): String =
withContext(Dispatchers.Main) {
val wv = webView ?: throw IllegalStateException("no webview")
val bmp = wv.captureBitmap()
try {
val scaled = bmp.scaleForMaxWidth(maxWidth)
try {
val out = ByteArrayOutputStream()
scaled.compress(Bitmap.CompressFormat.PNG, 100, out)
Base64.encodeToString(out.toByteArray(), Base64.NO_WRAP)
} finally {
if (scaled !== bmp) scaled.recycle()
}
} finally {
bmp.recycle()
}
}
/** Captures the WebView as PNG/JPEG base64 with optional width and quality bounds. */
suspend fun snapshotBase64(
format: SnapshotFormat,

View File

@@ -109,3 +109,6 @@ fun normalizeMainKey(raw: String?): String? {
val trimmed = raw?.trim().orEmpty()
return if (trimmed.isEmpty()) null else trimmed
}
/** Returns true only for the canonical main-session key understood by gateway UI. */
fun isCanonicalMainSessionKey(key: String): Boolean = key == "main"

View File

@@ -206,6 +206,9 @@ internal fun decodeGatewaySetupCode(rawInput: String): GatewaySetupCode? {
}
}
/** Extracts a setup code from QR scanner text when the embedded endpoint is valid. */
internal fun resolveScannedSetupCode(rawInput: String): String? = resolveScannedSetupCodeResult(rawInput).setupCode
/** Resolves QR scanner text to setup-code or validation error for UI copy. */
internal fun resolveScannedSetupCodeResult(rawInput: String): GatewayScannedSetupCodeResult {
val setupCode =

View File

@@ -9,10 +9,14 @@ import androidx.compose.material3.dynamicLightColorScheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.platform.LocalView
import androidx.core.view.WindowCompat
private val LocalOpenClawDarkTheme = staticCompositionLocalOf { true }
/**
* App theme wrapper that installs dynamic Material colors and legacy mobile color tokens.
*/
@@ -30,6 +34,7 @@ fun OpenClawTheme(
CompositionLocalProvider(
LocalMobileColors provides mobileColors,
LocalOpenClawDarkTheme provides isDark,
) {
MaterialTheme(colorScheme = colorScheme, content = content)
}
@@ -50,3 +55,21 @@ internal fun OpenClawSystemBarAppearance(lightAppearance: Boolean) {
}
}
}
/**
* Overlay background token tuned for panels floating over the mobile canvas.
*/
@Composable
fun overlayContainerColor(): Color {
val scheme = MaterialTheme.colorScheme
val isDark = LocalOpenClawDarkTheme.current
val base = if (isDark) scheme.surfaceContainerLow else scheme.surfaceContainerHigh
// Light mode keeps overlays away from pure-white glare on the app canvas.
return if (isDark) base else base.copy(alpha = 0.88f)
}
/**
* Overlay icon token kept next to overlayContainerColor for callers outside the design package.
*/
@Composable
fun overlayIconColor(): Color = MaterialTheme.colorScheme.onSurfaceVariant

View File

@@ -1253,6 +1253,16 @@ private fun settingsPrimaryButtonColors() =
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Destructive button colors for permission and capability settings actions. */
@Composable
private fun settingsDangerButtonColors() =
ButtonDefaults.buttonColors(
containerColor = mobileDanger,
contentColor = Color.White,
disabledContainerColor = mobileDanger.copy(alpha = 0.45f),
disabledContentColor = Color.White.copy(alpha = 0.9f),
)
/** Opens this app's Android settings page for permissions that require system UI. */
private fun openAppSettings(context: Context) {
val intent =

View File

@@ -709,6 +709,16 @@ private fun ModuleListRow(
}
}
@Composable
private fun RecentSessionRow(
title: String,
subtitle: String,
metadata: String,
onClick: () -> Unit,
) {
RecentSessionRowContent(title = title, subtitle = subtitle, metadata = metadata, onClick = onClick)
}
private data class RecentSessionListItem(
val key: String,
val title: String,

View File

@@ -973,6 +973,24 @@ private fun userFacingChatError(error: String): String {
}
}
/** Normalizes persisted thinking values into compact UI labels. */
private fun thinkingDisplay(value: String): String =
when (value.lowercase(Locale.US)) {
"low" -> "Low"
"medium" -> "Medium"
"high" -> "High"
else -> "Off"
}
/** Converts displayed thinking labels back to gateway request values. */
private fun thinkingValue(display: String): String =
when (display.lowercase(Locale.US)) {
"low" -> "low"
"medium" -> "medium"
"high" -> "high"
else -> "off"
}
/** Cycles through context budget presets from the compact composer control. */
private fun nextThinkingValue(value: String): String =
when (value.lowercase(Locale.US)) {

View File

@@ -37,6 +37,28 @@ internal fun ClawPanel(
}
}
/**
* Bottom-sheet container with the app surface treatment and top-only rounding.
*/
@Composable
internal fun ClawSheetSurface(
modifier: Modifier = Modifier,
contentPadding: PaddingValues = PaddingValues(18.dp),
content: @Composable () -> Unit,
) {
Surface(
modifier = modifier.fillMaxWidth(),
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
color = ClawTheme.colors.surface,
contentColor = ClawTheme.colors.text,
border = BorderStroke(1.dp, ClawTheme.colors.border),
) {
Column(modifier = Modifier.padding(contentPadding)) {
content()
}
}
}
/**
* Shared empty state used when a screen has no records but can still offer an action.
*/

View File

@@ -4,6 +4,7 @@ import ai.openclaw.app.ui.LocalMobileColors
import ai.openclaw.app.ui.darkMobileColors
import ai.openclaw.app.ui.lightMobileColors
import ai.openclaw.app.ui.mobileFontFamily
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Shapes
import androidx.compose.material3.Typography
@@ -189,6 +190,12 @@ internal fun ClawDesignTheme(
}
}
/**
* Returns the system dark-mode preference for callers that expose theme selection.
*/
@Composable
internal fun rememberClawDarkPreference(): Boolean = isSystemInDarkTheme()
private fun clawTypography(fontFamily: FontFamily) =
ClawTypography(
display =

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -44,7 +43,7 @@ data class VoiceConversationEntry(
)
/** Coordinates live mic transcription, queued sends, and assistant audio replies. */
internal class MicCaptureManager(
class MicCaptureManager(
private val context: Context,
private val scope: CoroutineScope,
private val createTranscriptionSession: suspend () -> String,
@@ -55,12 +54,11 @@ internal class MicCaptureManager(
) -> Unit,
private val closeTranscriptionSession: suspend (sessionId: String) -> Unit,
/**
* Send [message] to the gateway and return the full chat.send ACK.
* Send [message] to the gateway and return the run ID.
* [onRunIdKnown] is called with the idempotency key *before* the network
* round-trip so [pendingRunId] is set before any chat events can arrive.
*/
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> ChatSendAck,
private val refreshAfterTerminalSuccess: suspend () -> Unit = {},
private val sendToGateway: suspend (message: String, onRunIdKnown: (String) -> Unit) -> String?,
private val speakAssistantReply: suspend (String) -> Unit = {},
) {
companion object {
@@ -485,30 +483,24 @@ internal class MicCaptureManager(
scope.launch {
try {
val ack =
val runId =
sendToGateway(next) { earlyRunId ->
// Called with the idempotency key before chat.send fires so that
// pendingRunId is populated before any chat events can arrive.
pendingRunId = earlyRunId
}
val runId = ack.runId
// Update to the real runId if the gateway returned a different one.
if (runId != null && runId != pendingRunId) pendingRunId = runId
when {
ack.isTerminalSuccess -> {
completePendingTurn()
refreshAfterTerminalSuccess()
}
ack.isTerminalFailure -> {
completePendingTurn()
_statusText.value = "Send failed: Chat failed before the run started; try again."
}
runId == null -> {
completePendingTurn()
}
else -> {
armPendingRunTimeout(runId)
}
if (runId == null) {
pendingRunTimeoutJob?.cancel()
pendingRunTimeoutJob = null
removeFirstQueuedMessage()
publishQueue()
_isSending.value = false
pendingAssistantEntryId = null
sendQueuedIfIdle()
} else {
armPendingRunTimeout(runId)
}
} catch (err: Throwable) {
pendingRunTimeoutJob?.cancel()

View File

@@ -1,9 +1,6 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import ai.openclaw.app.gateway.GatewaySession
import ai.openclaw.app.gateway.chatSendAckHistorySinceSeconds
import ai.openclaw.app.gateway.parseChatSendAck
import android.Manifest
import android.annotation.SuppressLint
import android.content.Context
@@ -111,6 +108,7 @@ class TalkModeManager internal constructor(
private const val tag = "TalkMode"
private const val realtimeSampleRateHz = 24_000
private const val realtimeAudioFrameMs = 100
private const val listenWatchdogMs = 12_000L
private const val chatFinalWaitMs = 45_000L
private const val maxCachedRunCompletions = 128
private const val maxConversationEntries = 40
@@ -383,20 +381,11 @@ class TalkModeManager internal constructor(
reloadConfig()
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
val prompt = buildPrompt(command)
val ack = sendChat(prompt, session)
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
if (ack.isTerminalFailure) {
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
return@launch
}
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
val runId = sendChat(prompt, session)
val ok = waitForChatFinal(runId)
val assistant =
consumeRunText(runId)
?: waitForAssistantText(
session,
chatSendAckHistorySinceSeconds(ack, startedAt),
if (ok) 12_000 else 25_000,
)
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
if (!assistant.isNullOrBlank()) {
val playbackToken = playbackGeneration.incrementAndGet()
cancelActivePlayback()
@@ -409,9 +398,8 @@ class TalkModeManager internal constructor(
}
} catch (err: Throwable) {
Log.w(tag, "speakWakeCommand failed: ${err.message}")
} finally {
onComplete()
}
onComplete()
}
}
@@ -1616,26 +1604,16 @@ class TalkModeManager internal constructor(
try {
val startedAt = System.currentTimeMillis().toDouble() / 1000.0
Log.d(tag, "chat.send start sessionKey=${mainSessionKey.ifBlank { "main" }} chars=${prompt.length}")
val ack = sendChat(prompt, session)
val runId = ack.runId ?: throw IllegalStateException("chat.send returned no run id")
Log.d(tag, "chat.send ok runId=$runId status=${ack.status}")
if (ack.isTerminalFailure) {
_statusText.value = if (ack.normalizedStatus == "error") "Chat error" else "Aborted"
start()
return
}
val ok = if (ack.isTerminalSuccess) true else waitForChatFinal(runId)
val runId = sendChat(prompt, session)
Log.d(tag, "chat.send ok runId=$runId")
val ok = waitForChatFinal(runId)
if (!ok) {
Log.w(tag, "chat final timeout runId=$runId; attempting history fallback")
}
// Use text cached from the final event first — avoids chat.history polling
val assistant =
consumeRunText(runId)
?: waitForAssistantText(
session,
chatSendAckHistorySinceSeconds(ack, startedAt),
if (ok) 12_000 else 25_000,
)
?: waitForAssistantText(session, startedAt, if (ok) 12_000 else 25_000)
if (assistant.isNullOrBlank()) {
_statusText.value = "No reply"
Log.w(tag, "assistant text timeout runId=$runId")
@@ -1701,7 +1679,7 @@ class TalkModeManager internal constructor(
private suspend fun sendChat(
message: String,
session: GatewaySession,
): ChatSendAck {
): String {
val runId = UUID.randomUUID().toString()
armPendingRun(runId)
val params =
@@ -1714,15 +1692,11 @@ class TalkModeManager internal constructor(
}
try {
val res = session.request("chat.send", params.toString())
val parsed = parseChatSendAck(json, res)
val actualRunId = parsed.runId ?: runId
if (actualRunId != runId) {
pendingRunId = actualRunId
val parsed = parseRunId(res) ?: runId
if (parsed != runId) {
pendingRunId = parsed
}
if (parsed.isTerminal) {
clearPendingRun(actualRunId)
}
return parsed.copy(runId = actualRunId)
return parsed
} catch (err: Throwable) {
clearPendingRun(runId)
throw err
@@ -1803,7 +1777,7 @@ class TalkModeManager internal constructor(
private suspend fun waitForAssistantText(
session: GatewaySession,
sinceSeconds: Double?,
sinceSeconds: Double,
timeoutMs: Long,
): String? {
val deadline = SystemClock.elapsedRealtime() + timeoutMs

View File

@@ -33,44 +33,44 @@ class GatewayBootstrapAuthTest {
@Test
fun doesNotConnectOperatorSessionWhenOnlyBootstrapAuthExists() {
assertFalse(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "", bootstrapToken = "bootstrap-1", password = ""),
storedOperatorToken = "",
) != null,
),
)
assertFalse(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
) != null,
),
)
}
@Test
fun connectsOperatorSessionWhenSharedPasswordOrStoredAuthExists() {
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = "shared-token", bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = null,
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = "shared-password"),
storedOperatorToken = null,
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "bootstrap-1", password = null),
storedOperatorToken = "stored-token",
) != null,
),
)
assertTrue(
resolveOperatorSessionConnectAuth(
shouldConnectOperatorSession(
NodeRuntime.GatewayConnectAuth(token = null, bootstrapToken = "", password = null),
storedOperatorToken = null,
) != null,
),
)
}

View File

@@ -1,144 +0,0 @@
package ai.openclaw.app.chat
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatControllerTerminalAckTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalTimeoutAckRemovesOptimisticUserEchoAndSurfacesFailedAcceptance() =
runTest {
var requestedMethod: String? = null
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
requestedMethod = method
"""{"runId":"run-timeout","status":"timeout"}"""
},
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that times out before start",
thinkingLevel = "off",
attachments = emptyList(),
)
assertFalse(accepted)
assertEquals("chat.send", requestedMethod)
assertEquals(0, controller.pendingRunCount.value)
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that times out before start"))
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun nonTerminalStartedAckRetainsOptimisticUserEchoAndPendingRun() =
runTest {
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-started","status":"started"}""" },
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that started",
thinkingLevel = "off",
attachments = emptyList(),
)
assertTrue(accepted)
assertEquals(1, controller.pendingRunCount.value)
assertNull(controller.errorText.value)
assertTrue(controller.messages.value.hasUserText("message that started"))
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalOkAckClearsOptimisticUserEchoAndRefreshesHistory() =
runTest {
val requestedMethods = mutableListOf<String>()
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { method, _ ->
requestedMethods += method
when (method) {
"chat.send" -> """{"runId":"run-ok","status":"ok"}"""
"chat.history" ->
"""
{
"sessionId": "session-1",
"messages": [
{ "role": "assistant", "content": "cached success reply", "timestamp": 1 }
]
}
""".trimIndent()
else -> "{}"
}
},
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that already completed",
thinkingLevel = "off",
attachments = emptyList(),
)
advanceUntilIdle()
assertTrue(accepted)
assertEquals(listOf("chat.send", "chat.history"), requestedMethods)
assertEquals(0, controller.pendingRunCount.value)
assertNull(controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that already completed"))
assertTrue(controller.messages.value.any { message -> message.role == "assistant" && message.content.any { part -> part.text == "cached success reply" } })
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalErrorAckRemovesOptimisticUserEchoAndSurfacesErrorText() =
runTest {
val controller =
ChatController(
scope = this,
json = json,
requestGateway = { _, _ -> """{"runId":"run-error","status":"error"}""" },
)
controller.handleGatewayEvent("health", null)
val accepted =
controller.sendMessageAwaitAcceptance(
message = "message that errors before start",
thinkingLevel = "off",
attachments = emptyList(),
)
assertFalse(accepted)
assertEquals(0, controller.pendingRunCount.value)
assertEquals("Chat failed before the run started; try again.", controller.errorText.value)
assertFalse(controller.messages.value.hasUserText("message that errors before start"))
}
private fun List<ChatMessage>.hasUserText(text: String): Boolean =
any { message ->
message.role == "user" && message.content.any { part -> part.text == text }
}
}

View File

@@ -1,68 +0,0 @@
package ai.openclaw.app.gateway
import kotlinx.serialization.json.Json
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
class ChatSendAckTest {
private val json = Json { ignoreUnknownKeys = true }
@Test
fun parseChatSendAckPreservesNonTerminalStartedStatus() {
val ack = parseChatSendAck(json, """{"runId":"run-1","status":"started"}""")
assertEquals("run-1", ack.runId)
assertEquals("started", ack.normalizedStatus)
assertFalse(ack.isTerminal)
}
@Test
fun parseChatSendAckMarksOkAsTerminalSuccess() {
val ack = parseChatSendAck(json, """{"runId":"run-ok","status":" ok "}""")
assertEquals("run-ok", ack.runId)
assertEquals("ok", ack.normalizedStatus)
assertTrue(ack.isTerminal)
assertTrue(ack.isTerminalSuccess)
assertFalse(ack.isTerminalFailure)
}
@Test
fun parseChatSendAckMarksTimeoutAndErrorAsTerminalFailures() {
val timeout = parseChatSendAck(json, """{"runId":"run-timeout","status":"timeout"}""")
val error = parseChatSendAck(json, """{"runId":"run-error","status":" error "}""")
assertEquals("run-timeout", timeout.runId)
assertTrue(timeout.isTerminal)
assertFalse(timeout.isTerminalSuccess)
assertTrue(timeout.isTerminalFailure)
assertEquals("run-error", error.runId)
assertTrue(error.isTerminal)
assertFalse(error.isTerminalSuccess)
assertTrue(error.isTerminalFailure)
}
@Test
fun cachedOkAckUsesUnfilteredHistoryFallback() {
val startedAt = 123.0
val ok = parseChatSendAck(json, """{"runId":"run-ok","status":"ok"}""")
val started = parseChatSendAck(json, """{"runId":"run-started","status":"started"}""")
assertNull(chatSendAckHistorySinceSeconds(ok, startedAt))
assertEquals(startedAt, chatSendAckHistorySinceSeconds(started, startedAt) ?: -1.0, 0.0)
}
@Test
fun parseChatSendAckToleratesMalformedPayloads() {
val ack = parseChatSendAck(json, "not-json")
assertNull(ack.runId)
assertEquals("", ack.normalizedStatus)
assertFalse(ack.isTerminal)
assertFalse(ack.isTerminalSuccess)
assertFalse(ack.isTerminalFailure)
}
}

View File

@@ -204,18 +204,17 @@ class GatewayConfigResolverTest {
}
@Test
fun resolveScannedSetupCodeResultAcceptsRawSetupCode() {
fun resolveScannedSetupCodeAcceptsRawSetupCode() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeResultAcceptsQrJsonPayload() {
fun resolveScannedSetupCodeAcceptsQrJsonPayload() {
val setupCode =
encodeSetupCode("""{"url":"wss://gateway.example:18789","bootstrapToken":"bootstrap-1"}""")
val qrJson =
@@ -228,55 +227,49 @@ class GatewayConfigResolverTest {
}
""".trimIndent()
val resolved = resolveScannedSetupCodeResult(qrJson)
val resolved = resolveScannedSetupCode(qrJson)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsInvalidInput() {
val resolved = resolveScannedSetupCodeResult("not-a-valid-setup-code")
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
fun resolveScannedSetupCodeRejectsInvalidInput() {
val resolved = resolveScannedSetupCode("not-a-valid-setup-code")
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsJsonWithInvalidSetupCode() {
fun resolveScannedSetupCodeRejectsJsonWithInvalidSetupCode() {
val qrJson = """{"setupCode":"invalid"}"""
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsJsonWithNonStringSetupCode() {
fun resolveScannedSetupCodeRejectsJsonWithNonStringSetupCode() {
val qrJson = """{"setupCode":{"nested":"value"}}"""
val resolved = resolveScannedSetupCodeResult(qrJson)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INVALID_URL, resolved.error)
val resolved = resolveScannedSetupCode(qrJson)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultRejectsNonLoopbackCleartextGateway() {
fun resolveScannedSetupCodeRejectsNonLoopbackCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://attacker.example:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertNull(resolved.setupCode)
assertEquals(GatewayEndpointValidationError.INSECURE_REMOTE_URL, resolved.error)
assertNull(resolved)
}
@Test
fun resolveScannedSetupCodeResultAcceptsPrivateLanCleartextGateway() {
fun resolveScannedSetupCodeAcceptsPrivateLanCleartextGateway() {
val setupCode =
encodeSetupCode("""{"url":"ws://192.168.31.100:18789","bootstrapToken":"bootstrap-1"}""")
val resolved = resolveScannedSetupCodeResult(setupCode)
val resolved = resolveScannedSetupCode(setupCode)
assertEquals(setupCode, resolved.setupCode)
assertNull(resolved.error)
assertEquals(setupCode, resolved)
}
@Test

View File

@@ -1,6 +1,5 @@
package ai.openclaw.app.voice
import ai.openclaw.app.gateway.ChatSendAck
import android.Manifest
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
@@ -35,7 +34,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
ChatSendAck(runId = "run-1", status = "started")
null
},
)
@@ -85,7 +84,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-1")
ChatSendAck(runId = "run-1", status = "started")
"run-1"
},
)
@@ -112,7 +111,7 @@ class MicCaptureManagerTest {
sendToGateway = { message, onRunIdKnown ->
sentMessages += message
onRunIdKnown("run-voice-e2e")
ChatSendAck(runId = "run-voice-e2e", status = "started")
"run-voice-e2e"
},
)
@@ -135,88 +134,6 @@ class MicCaptureManagerTest {
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayTimeoutSendDoesNotAcceptDelayedOldRunEvents() =
runTest {
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-terminal")
ChatSendAck(runId = "run-terminal", status = "timeout")
},
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal ack message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
manager.handleGatewayEvent("chat", chatFinalPayload(runId = "run-terminal", text = "stale reply"))
advanceUntilIdle()
assertEquals(
listOf(VoiceConversationRole.User),
manager.conversation.value.map { it.role },
)
assertEquals(
"terminal ack message",
manager.conversation.value
.single()
.text,
)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayErrorSurfacesFailureWithoutWaitingForRunEvents() =
runTest {
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-error")
ChatSendAck(runId = "run-error", status = "error")
},
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal error message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals("Send failed: Chat failed before the run started; try again.", manager.statusText.value)
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun terminalGatewayOkRefreshesHistoryWithoutWaitingForRunEvents() =
runTest {
var refreshCalls = 0
val manager =
createManager(
scope = this,
sendToGateway = { _, onRunIdKnown ->
onRunIdKnown("run-ok")
ChatSendAck(runId = "run-ok", status = "ok")
},
refreshAfterTerminalSuccess = { refreshCalls += 1 },
)
manager.onGatewayConnectionChanged(true)
manager.submitTranscribedMessage("terminal ok message")
runCurrent()
assertNull(privateField<String?>(manager, "pendingRunId"))
assertEquals(false, manager.isSending.value)
assertEquals(1, refreshCalls)
}
@Test
fun pcm16FramesAreEncodedAsPcmuFrames() {
val manager = createManager()
@@ -313,11 +230,10 @@ class MicCaptureManagerTest {
scope: CoroutineScope = CoroutineScope(Dispatchers.Unconfined),
createTranscriptionSession: suspend () -> String = { "transcription-1" },
closeTranscriptionSession: suspend (String) -> Unit = { _ -> },
sendToGateway: suspend (String, (String) -> Unit) -> ChatSendAck = { _, onRunIdKnown ->
sendToGateway: suspend (String, (String) -> Unit) -> String? = { _, onRunIdKnown ->
onRunIdKnown("run-1")
ChatSendAck(runId = "run-1", status = "started")
"run-1"
},
refreshAfterTerminalSuccess: suspend () -> Unit = {},
): MicCaptureManager =
MicCaptureManager(
context =
@@ -329,7 +245,6 @@ class MicCaptureManagerTest {
appendTranscriptionAudio = { _, _, _ -> },
closeTranscriptionSession = closeTranscriptionSession,
sendToGateway = sendToGateway,
refreshAfterTerminalSuccess = refreshAfterTerminalSuccess,
)
private fun setPrivateField(

View File

@@ -2,24 +2,8 @@ parent_config: ../../config/swiftlint.yml
included:
- Sources
- ShareExtension
- ActivityWidget
- WatchApp
- ../shared/OpenClawKit/Sources/OpenClawChatUI
excluded:
- ../macos
- ../shared/ClawdisNodeKit/Sources
type_body_length:
warning: 900
error: 1300
custom_rules:
openclaw_design_colors:
name: "OpenClaw design colors"
excluded:
- Sources/Design/OpenClawBrand.swift
- ../shared/OpenClawKit/Sources/OpenClawChatUI/ChatTheme.swift
regex: '(Color\.accentColor|(^|[^A-Za-z0-9_])\.accentColor\b|Color\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|\.(foregroundStyle|tint|fill|stroke|strokeBorder|background)\(\s*\.(red|green|orange|cyan|blue|yellow|purple|pink)\b|Color\(red:\s*0\s*/\s*255\.0,\s*green:\s*122\s*/\s*255\.0,\s*blue:\s*255\s*/\s*255\.0\))'
message: "Use OpenClawBrand or OpenClawChatTheme design tokens instead of raw accent/status colors."
severity: error

View File

@@ -74,30 +74,23 @@ struct OpenClawLiveActivity: Widget {
private func statusIcon(state: OpenClawActivityAttributes.ContentState) -> some View {
if state.isConnecting {
Image(systemName: "arrow.triangle.2.circlepath")
.foregroundStyle(OpenClawActivityStyle.info)
.foregroundStyle(.cyan)
} else if state.isDisconnected {
Image(systemName: "wifi.slash")
.foregroundStyle(OpenClawActivityStyle.danger)
.foregroundStyle(.red)
} else if state.isIdle {
Image(systemName: "checkmark")
.foregroundStyle(OpenClawActivityStyle.ok)
.foregroundStyle(.green)
} else {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(OpenClawActivityStyle.warn)
.foregroundStyle(.orange)
}
}
private func dotColor(state: OpenClawActivityAttributes.ContentState) -> Color {
if state.isDisconnected { return OpenClawActivityStyle.danger }
if state.isConnecting { return OpenClawActivityStyle.info }
if state.isIdle { return OpenClawActivityStyle.ok }
return OpenClawActivityStyle.warn
if state.isDisconnected { return .red }
if state.isConnecting { return .cyan }
if state.isIdle { return .green }
return .orange
}
}
private enum OpenClawActivityStyle {
static let info = Color(red: 0, green: 122 / 255.0, blue: 1)
static let danger = Color(red: 185 / 255.0, green: 28 / 255.0, blue: 28 / 255.0)
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
}

View File

@@ -506,7 +506,7 @@ extension AgentProTab {
func skillEditorSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()

View File

@@ -105,7 +105,7 @@ struct AgentProTab: View {
var color: Color {
switch self {
case .online: OpenClawBrand.ok
case .ready: OpenClawBrand.info
case .ready: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
}
}
}

View File

@@ -277,7 +277,7 @@ struct ChatProTab: View {
}
private var chatUserAccent: Color {
self.colorScheme == .light ? OpenClawBrand.info : OpenClawBrand.accent
self.colorScheme == .light ? Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0) : OpenClawBrand.accent
}
private var activeAgent: AgentSummary? {

View File

@@ -1036,7 +1036,7 @@ struct IPadSkillProposalRow: View {
.padding(.horizontal, 14)
.padding(.vertical, 10)
.background(
self.isSelected ? OpenClawBrand.danger.opacity(0.08) : Color.clear,
self.isSelected ? Color.red.opacity(0.08) : Color.clear,
in: RoundedRectangle(cornerRadius: 8, style: .continuous))
}
}

View File

@@ -47,13 +47,11 @@ enum AppAppearancePreference: String, CaseIterable, Identifiable {
}
enum OpenClawBrand {
static let uiAccent = UIColor { traits in
static let accent = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 198 / 255.0, green: 62 / 255.0, blue: 56 / 255.0, alpha: 1)
: UIColor(red: 183 / 255.0, green: 56 / 255.0, blue: 51 / 255.0, alpha: 1)
}
static let accent = Color(uiColor: Self.uiAccent)
})
static let accentHot = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 232 / 255.0, green: 92 / 255.0, blue: 86 / 255.0, alpha: 1)
@@ -66,7 +64,6 @@ enum OpenClawBrand {
})
static let ok = Color(red: 34 / 255.0, green: 197 / 255.0, blue: 94 / 255.0)
static let warn = Color(red: 245 / 255.0, green: 158 / 255.0, blue: 11 / 255.0)
static let info = Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0)
static let graphite = Color(uiColor: UIColor { traits in
traits.userInterfaceStyle == .dark
? UIColor(red: 20 / 255.0, green: 22 / 255.0, blue: 24 / 255.0, alpha: 1)

View File

@@ -119,7 +119,7 @@ extension SettingsProTab {
self.gatewayActionButton(
title: "Diagnose",
icon: "cross.case",
color: OpenClawBrand.info,
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
@@ -476,7 +476,7 @@ extension SettingsProTab {
self.gatewayActionButton(
title: "Run Diagnostics",
icon: "cross.case",
color: OpenClawBrand.info,
color: Color(red: 0 / 255.0, green: 122 / 255.0, blue: 255 / 255.0),
isBusy: self.isRefreshingGateway)
{
Task { await self.runDiagnostics() }
@@ -1040,7 +1040,7 @@ extension SettingsProTab {
func settingsSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()

View File

@@ -90,7 +90,7 @@ private struct ExecApprovalPromptCard: View {
if let errorText = self.normalized(self.errorText) {
Text(errorText)
.font(.footnote)
.foregroundStyle(OpenClawBrand.danger)
.foregroundStyle(.red)
}
if self.isResolving {

View File

@@ -40,7 +40,7 @@ struct OnboardingIntroStep: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.title3.weight(.semibold))
.foregroundStyle(OpenClawBrand.warn)
.foregroundStyle(.orange)
.frame(width: 24)
.padding(.top, 2)
@@ -177,7 +177,7 @@ struct OnboardingModeRow: View {
}
Spacer()
Image(systemName: self.selected ? "checkmark.circle.fill" : "circle")
.foregroundStyle(self.selected ? OpenClawBrand.accent : Color.secondary)
.foregroundStyle(self.selected ? Color.accentColor : Color.secondary)
}
.contentShape(Rectangle())
}

View File

@@ -378,7 +378,7 @@ struct OnboardingWizardView: View {
private func onboardingSwitchIndicator(isOn: Bool) -> some View {
Capsule()
.fill(isOn ? OpenClawBrand.accent : Color.secondary.opacity(0.35))
.fill(isOn ? Color.accentColor : Color.secondary.opacity(0.35))
.frame(width: 52, height: 32)
.overlay(alignment: isOn ? .trailing : .leading) {
Circle()
@@ -575,7 +575,7 @@ struct OnboardingWizardView: View {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 64))
.foregroundStyle(OpenClawBrand.ok)
.foregroundStyle(.green)
.padding(.bottom, 20)
Text("Connected")

View File

@@ -632,7 +632,6 @@ struct OpenClawApp: App {
var body: some Scene {
WindowGroup {
RootTabs()
.tint(OpenClawBrand.accent)
.preferredColorScheme(self.appearancePreference.colorScheme)
.environment(self.appModel)
.environment(self.appModel.voiceWake)
@@ -687,7 +686,6 @@ struct OpenClawApp: App {
.flatMap(\.windows)
.forEach { window in
window.overrideUserInterfaceStyle = style
window.tintColor = OpenClawBrand.uiAccent
}
}
}

View File

@@ -1008,61 +1008,40 @@ final class TalkModeManager: NSObject {
self.logger.info(
"chat.send start sessionKey=\(sessionKey, privacy: .public) chars=\(prompt.count, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send start sessionKey=\(sessionKey) chars=\(prompt.count)")
let ack = try await self.sendChat(prompt, gateway: gateway)
let runId = ack.runId
let normalizedStatus = Self.normalizedChatSendStatus(ack.status)
self.logger.info(
"chat.send ok runId=\(runId, privacy: .public) status=\(normalizedStatus, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send ok runId=\(runId) status=\(normalizedStatus)")
if Self.isTerminalChatSendFailure(ack.status) {
self.statusText = normalizedStatus == "error" ? "Chat error" : "Aborted"
self.logger.warning(
"chat.send terminal ack runId=\(runId, privacy: .public) status=\(normalizedStatus, privacy: .public)")
GatewayDiagnostics.log(
"talk: chat.send terminal ack runId=\(runId) status=\(normalizedStatus)")
if restartAfter {
await self.start()
}
return
}
let runId = try await self.sendChat(prompt, gateway: gateway)
self.logger.info("chat.send ok runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat.send ok runId=\(runId)")
let shouldIncremental = self.shouldUseIncrementalTTS()
var streamingTask: Task<Void, Never>?
let completion: ChatCompletionResult
if Self.isTerminalChatSendSuccess(ack.status) {
GatewayDiagnostics.log("talk: chat.send terminal ok runId=\(runId); using history fallback")
completion = ChatCompletionResult(state: .final, assistantText: nil)
} else {
if shouldIncremental {
self.resetIncrementalSpeech()
streamingTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.streamAssistant(runId: runId, gateway: gateway)
}
}
completion = await self.waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
if completion.state == .timeout {
self.logger.warning(
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)")
} else if completion.state == .aborted {
self.statusText = "Aborted"
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
} else if completion.state == .error {
self.statusText = "Chat error"
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion error runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
if shouldIncremental {
self.resetIncrementalSpeech()
streamingTask = Task { @MainActor [weak self] in
guard let self else { return }
await self.streamAssistant(runId: runId, gateway: gateway)
}
}
let completion = await waitForChatCompletion(runId: runId, gateway: gateway, timeoutSeconds: 120)
if completion.state == .timeout {
self.logger.warning(
"chat completion timeout runId=\(runId, privacy: .public); attempting history fallback")
GatewayDiagnostics.log("talk: chat completion timeout runId=\(runId)")
} else if completion.state == .aborted {
self.statusText = "Aborted"
self.logger.warning("chat completion aborted runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion aborted runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
} else if completion.state == .error {
self.statusText = "Chat error"
self.logger.warning("chat completion error runId=\(runId, privacy: .public)")
GatewayDiagnostics.log("talk: chat completion error runId=\(runId)")
streamingTask?.cancel()
await self.finishIncrementalSpeech()
await self.start()
return
}
var assistantText = completion.assistantText
if assistantText == nil, shouldIncremental {
@@ -1074,7 +1053,7 @@ final class TalkModeManager: NSObject {
if assistantText == nil {
assistantText = try await self.waitForAssistantTextFromHistory(
gateway: gateway,
since: Self.chatSendHistorySince(response: ack, startedAt: startedAt),
since: startedAt,
timeoutSeconds: completion.state == .final ? 12 : 25)
}
guard let assistantText else {
@@ -1364,27 +1343,8 @@ final class TalkModeManager: NSObject {
var assistantText: String?
}
private static func normalizedChatSendStatus(_ status: String) -> String {
status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private static func isTerminalChatSendSuccess(_ status: String) -> Bool {
self.normalizedChatSendStatus(status) == "ok"
}
private static func isTerminalChatSendFailure(_ status: String) -> Bool {
let normalized = self.normalizedChatSendStatus(status)
return normalized == "timeout" || normalized == "error"
}
private static func chatSendHistorySince(
response: OpenClawChatSendResponse,
startedAt: Double) -> Double?
{
self.isTerminalChatSendSuccess(response.status) ? nil : startedAt
}
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> OpenClawChatSendResponse {
private func sendChat(_ message: String, gateway: GatewayNodeSession) async throws -> String {
struct SendResponse: Decodable { let runId: String }
let payload: [String: Any] = [
"sessionKey": mainSessionKey,
"message": message,
@@ -1400,7 +1360,8 @@ final class TalkModeManager: NSObject {
userInfo: [NSLocalizedDescriptionKey: "Failed to encode chat payload"])
}
let res = try await gateway.request(method: "chat.send", paramsJSON: json, timeoutSeconds: 30)
return try JSONDecoder().decode(OpenClawChatSendResponse.self, from: res)
let decoded = try JSONDecoder().decode(SendResponse.self, from: res)
return decoded.runId
}
private func waitForChatCompletion(
@@ -1479,7 +1440,7 @@ final class TalkModeManager: NSObject {
private func waitForAssistantTextFromHistory(
gateway: GatewayNodeSession,
since: Double?,
since: Double,
timeoutSeconds: Int) async throws -> String?
{
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))

View File

@@ -35,7 +35,7 @@ struct TalkPermissionPromptView: View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: self.iconSystemName)
.font(.title3.weight(.semibold))
.foregroundStyle(self.requestIsPending ? OpenClawBrand.warn : OpenClawBrand.accent)
.foregroundStyle(self.requestIsPending ? Color.orange : Color.accentColor)
.frame(width: 28, height: 28)
VStack(alignment: .leading, spacing: 6) {
@@ -51,7 +51,7 @@ struct TalkPermissionPromptView: View {
if let failureMessage = self.state.failureMessage {
Label(failureMessage, systemImage: "exclamationmark.triangle.fill")
.font(.footnote)
.foregroundStyle(OpenClawBrand.danger)
.foregroundStyle(.red)
.fixedSize(horizontal: false, vertical: true)
}
@@ -99,7 +99,7 @@ struct TalkPermissionPromptView: View {
.overlay {
if self.style == .card || self.style == .sheet {
RoundedRectangle(cornerRadius: 12, style: .continuous)
.stroke(OpenClawBrand.accent.opacity(0.20), lineWidth: 1)
.stroke(Color.accentColor.opacity(0.20), lineWidth: 1)
}
}
.task(id: self.pollTaskKey) {

View File

@@ -680,6 +680,83 @@ struct GeneralSettings: View {
case .missingNode, .missingGateway, .incompatible, .error: .orange
}
}
private var healthCard: some View {
let snapshot = self.healthStore.snapshot
return VStack(alignment: .leading, spacing: 6) {
HStack(spacing: 8) {
Circle()
.fill(self.healthStore.state.tint)
.frame(width: 10, height: 10)
Text(self.healthStore.summaryLine)
.font(.callout.weight(.semibold))
}
if let snap = snapshot {
let linkId = snap.channelOrder?.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
}) ?? snap.channels.keys.first(where: {
if let summary = snap.channels[$0] { return summary.linked != nil }
return false
})
let linkLabel =
linkId.flatMap { snap.channelLabels?[$0] } ??
linkId?.capitalized ??
"Link channel"
let linkAge = linkId.flatMap { snap.channels[$0]?.authAgeMs }
Text("\(linkLabel) auth age: \(healthAgeString(linkAge))")
.font(.caption)
.foregroundStyle(.secondary)
Text("Session store: \(snap.sessions.path) (\(snap.sessions.count) entries)")
.font(.caption)
.foregroundStyle(.secondary)
if let recent = snap.sessions.recent.first {
let lastActivity = recent.updatedAt != nil
? relativeAge(from: Date(timeIntervalSince1970: (recent.updatedAt ?? 0) / 1000))
: "unknown"
Text("Last activity: \(recent.key) \(lastActivity)")
.font(.caption)
.foregroundStyle(.secondary)
}
Text("Last check: \(relativeAge(from: self.healthStore.lastSuccess))")
.font(.caption)
.foregroundStyle(.secondary)
} else if let error = self.healthStore.lastError {
Text(error)
.font(.caption)
.foregroundStyle(.red)
} else {
Text("Health check pending…")
.font(.caption)
.foregroundStyle(.secondary)
}
HStack(spacing: 12) {
Button {
Task { await self.healthStore.refresh(onDemand: true) }
} label: {
if self.healthStore.isRefreshing {
ProgressView().controlSize(.small)
} else {
Label("Run Health Check", systemImage: "arrow.clockwise")
}
}
.disabled(self.healthStore.isRefreshing)
Divider().frame(height: 18)
Button {
self.revealLogs()
} label: {
Label("Reveal Logs", systemImage: "doc.text.magnifyingglass")
}
}
}
.padding(12)
.background(Color.gray.opacity(0.08))
.cornerRadius(10)
}
}
private enum RemoteStatus: Equatable {
@@ -762,6 +839,11 @@ extension GeneralSettings {
}
}
private func healthAgeString(_ ms: Double?) -> String {
guard let ms else { return "unknown" }
return msToAge(ms)
}
#if DEBUG
struct GeneralSettings_Previews: PreviewProvider {
static var previews: some View {

View File

@@ -221,6 +221,16 @@ final class InstancesStore {
}
}
private func decodeAndApplyPresenceData(_ data: Data) {
do {
let decoded = try JSONDecoder().decode([PresenceEntry].self, from: data)
self.applyPresence(decoded)
} catch {
self.logger.error("presence decode from event failed: \(error.localizedDescription, privacy: .public)")
self.lastError = error.localizedDescription
}
}
func handlePresenceEventPayload(_ payload: OpenClawProtocol.AnyCodable) {
do {
let wrapper = try GatewayPayloadDecoding.decode(payload, as: PresenceEventPayload.self)

View File

@@ -16,6 +16,7 @@ struct OpenClawApp: App {
private let gatewayManager = GatewayProcessManager.shared
private let controlChannel = ControlChannel.shared
private let activityStore = WorkActivityStore.shared
private let connectivityCoordinator = GatewayConnectivityCoordinator.shared
@State private var statusItem: NSStatusItem?
@State private var isMenuPresented = false
@State private var isPanelVisible = false
@@ -33,7 +34,6 @@ struct OpenClawApp: App {
init() {
OpenClawLogging.bootstrapIfNeeded()
GatewayConnectivityCoordinator.shared.start()
Self.applyAttachOnlyOverrideIfNeeded()
_state = State(initialValue: AppStateStore.shared)

View File

@@ -1045,6 +1045,16 @@ extension MenuSessionsInjector {
return item
}
private func formatVersionLabel(_ version: String) -> String {
let trimmed = version.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return version }
if trimmed.hasPrefix("v") { return trimmed }
if let first = trimmed.unicodeScalars.first, CharacterSet.decimalDigits.contains(first) {
return "v\(trimmed)"
}
return trimmed
}
@objc
private func patchThinking(_ sender: NSMenuItem) {
guard let dict = sender.representedObject as? [String: Any],

View File

@@ -217,6 +217,18 @@ extension String? {
}
}
extension [String] {
fileprivate func dedupedPreserveOrder() -> [String] {
var seen = Set<String>()
var result: [String] = []
for item in self where !seen.contains(item) {
seen.insert(item)
result.append(item)
}
return result
}
}
enum SessionLoadError: LocalizedError {
case gatewayUnavailable(String)
case decodeFailed(String)

View File

@@ -391,40 +391,20 @@ actor TalkModeRuntime {
idempotencyKey: runId,
attachments: [])
guard self.isCurrent(gen) else { return }
let normalizedStatus = Self.normalizedChatSendStatus(response.status)
self.logger.info(
"talk chat.send ok runId=\(response.runId, privacy: .public) " +
"status=\(normalizedStatus, privacy: .public) " +
"session=\(sessionKey, privacy: .public)")
if Self.isTerminalChatSendFailure(response.status) {
self.logger.warning(
"talk chat.send terminal ack runId=\(response.runId, privacy: .public) " +
"status=\(normalizedStatus, privacy: .public)")
await self.resumeListeningIfNeeded()
return
}
var assistantText: String?
if Self.isTerminalChatSendSuccess(response.status) {
self.logger.info(
"talk chat.send terminal ok runId=\(response.runId, privacy: .public); " +
"using history fallback")
var assistantText = await self.waitForAssistantEventText(
sessionKey: sessionKey,
runId: response.runId,
timeoutSeconds: 45)
if assistantText == nil {
self.logger.warning("talk assistant event text missing; using history fallback")
assistantText = await self.waitForAssistantTextFromHistory(
sessionKey: sessionKey,
since: nil,
since: startedAt,
timeoutSeconds: 12)
} else {
assistantText = await self.waitForAssistantEventText(
sessionKey: sessionKey,
runId: response.runId,
timeoutSeconds: 45)
if assistantText == nil {
self.logger.warning("talk assistant event text missing; using history fallback")
assistantText = await self.waitForAssistantTextFromHistory(
sessionKey: sessionKey,
since: startedAt,
timeoutSeconds: 12)
}
}
guard let assistantText
else {
@@ -519,19 +499,6 @@ actor TalkModeRuntime {
}
}
private static func normalizedChatSendStatus(_ status: String) -> String {
status.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
}
private static func isTerminalChatSendSuccess(_ status: String) -> Bool {
self.normalizedChatSendStatus(status) == "ok"
}
private static func isTerminalChatSendFailure(_ status: String) -> Bool {
let normalized = self.normalizedChatSendStatus(status)
return normalized == "timeout" || normalized == "error"
}
private static func matchesSessionKey(_ incoming: String, _ current: String) -> Bool {
let incoming = incoming.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
let current = current.trimmingCharacters(in: .whitespacesAndNewlines).lowercased()
@@ -542,7 +509,7 @@ actor TalkModeRuntime {
private func waitForAssistantTextFromHistory(
sessionKey: String,
since: Double?,
since: Double,
timeoutSeconds: Int) async -> String?
{
let deadline = Date().addingTimeInterval(TimeInterval(timeoutSeconds))

View File

@@ -23,6 +23,7 @@ struct VoiceWakeSettings: View {
@State private var meterStartupTask: Task<Void, Never>?
@State private var availableLocales: [Locale] = []
@State private var triggerEntries: [TriggerEntry] = []
private let fieldLabelWidth: CGFloat = 140
private let controlWidth: CGFloat = 240
private let isPreview = ProcessInfo.processInfo.isPreview

View File

@@ -368,6 +368,31 @@ final class VoiceWakeTester {
}
}
private func holdUntilSilence(onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void) {
Task { [weak self] in
guard let self else { return }
let detectedAt = Date()
let hardStop = detectedAt.addingTimeInterval(6) // cap overall listen after trigger
while !self.isStopping {
let now = Date()
if now >= hardStop { break }
if let last = self.lastHeard, now.timeIntervalSince(last) >= silenceWindow {
break
}
try? await Task.sleep(nanoseconds: 200_000_000)
}
if !self.isStopping {
self.stop()
await MainActor.run { AppStateStore.shared.stopVoiceEars() }
if let detectedText {
self.logger.info("voice wake hold finished; len=\(detectedText.count)")
Task { @MainActor in onUpdate(.detected(detectedText)) }
}
}
}
}
private func scheduleSilenceCheck(
triggers: [String],
onUpdate: @escaping @Sendable (VoiceWakeTestState) -> Void)

View File

@@ -283,7 +283,7 @@ struct OpenClawChatComposer: View {
}
.padding(.horizontal, 8)
.padding(.vertical, 5)
.background(OpenClawChatTheme.accent.opacity(0.08))
.background(Color.accentColor.opacity(0.08))
.clipShape(Capsule())
}
}
@@ -550,7 +550,7 @@ struct OpenClawChatComposer: View {
.frame(width: self.sendButtonSize, height: self.sendButtonSize)
.background(
RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous)
.fill(OpenClawChatTheme.danger))
.fill(Color.red))
.contentShape(RoundedRectangle(cornerRadius: self.sendButtonCornerRadius, style: .continuous))
.accessibilityLabel("Stop response")
.disabled(self.viewModel.isAborting)

View File

@@ -57,7 +57,7 @@ private struct ChatMarkdownStyle: ViewModifier {
}
private var inlineStyle: InlineStyle {
let linkColor: Color = self.context == .user ? self.textColor : OpenClawChatTheme.accent
let linkColor: Color = self.context == .user ? self.textColor : .accentColor
let codeScale: CGFloat = self.variant == .compact ? 0.85 : 0.9
return InlineStyle()
.code(.monospaced, .fontScale(codeScale))

View File

@@ -25,7 +25,7 @@ struct ChatAgentAvatar: View {
.fill(
LinearGradient(
colors: [
(self.tint ?? OpenClawChatTheme.accent).opacity(0.95),
(self.tint ?? Color.accentColor).opacity(0.95),
Color(red: 38 / 255.0, green: 40 / 255.0, blue: 43 / 255.0),
],
startPoint: .topLeading,
@@ -33,7 +33,7 @@ struct ChatAgentAvatar: View {
.overlay(
Circle()
.strokeBorder(Color.white.opacity(0.18), lineWidth: 1))
.shadow(color: (self.tint ?? OpenClawChatTheme.accent).opacity(0.18), radius: 8, y: 4)
.shadow(color: (self.tint ?? Color.accentColor).opacity(0.18), radius: 8, y: 4)
.accessibilityLabel(self.name.map { "\($0) avatar" } ?? "Agent avatar")
}

View File

@@ -152,18 +152,6 @@ enum OpenClawChatTheme {
#endif
}
static var accent: Color {
self.userBubble
}
static var danger: Color {
#if os(macOS)
Color(nsColor: .systemRed)
#else
Color(uiColor: .systemRed)
#endif
}
static var assistantBubble: Color {
#if os(macOS)
Color(nsColor: self.assistantBubbleDynamicNSColor)

View File

@@ -235,7 +235,7 @@ private struct OpenClawChatPreviewTransport: OpenClawChatTransport {
showsSessionSwitcher: false,
style: .onboarding,
markdownVariant: .standard,
userAccent: OpenClawChatTheme.accent)
userAccent: .blue)
}
private struct OpenClawChatPreview: View {
@@ -250,7 +250,7 @@ private struct OpenClawChatPreview: View {
showsSessionSwitcher: true,
style: .standard,
markdownVariant: .standard,
userAccent: OpenClawChatTheme.accent,
userAccent: .blue,
showsAssistantTrace: true)
}
}

View File

@@ -372,7 +372,7 @@ public struct OpenClawChatView: View {
systemImage: "bubble.left.and.bubble.right.fill",
title: self.emptyStateTitle,
message: self.emptyStateMessage,
tint: OpenClawChatTheme.accent,
tint: .accentColor,
actionTitle: nil,
action: nil)
.padding(.horizontal, 24)

View File

@@ -868,32 +868,25 @@ public final class OpenClawChatViewModel {
self.pendingLocalUserEchoMessageIDsByRunID[response.runId] = pendingUserMessageID
self.armPendingRunTimeout(runId: response.runId)
}
if response.status == "ok" {
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot) else { return }
self.finishPendingRunAfterTerminalOkSendAck(response)
} else if !self.finishPendingRunIfTerminalSendAck(response) {
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot) else { return }
if !self.clearPendingRunIfAssistantMessagePresent(
let historyContext = self.beginHistoryRequest(for: sessionSnapshot)
await self.refreshHistoryAfterRun(historyRequest: historyContext)
guard self.isCurrentSession(sessionSnapshot) else { return }
if !self.clearPendingRunIfAssistantMessagePresent(
runId: response.runId,
after: userMessageTimestamp)
{
self.armPostSendRefreshFallback(
runId: response.runId,
after: userMessageTimestamp)
{
self.armPostSendRefreshFallback(
runId: response.runId,
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
self.armRunCompletionRefresh(
runId: response.runId,
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
}
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
self.armRunCompletionRefresh(
runId: response.runId,
sessionSnapshot: sessionSnapshot,
userMessageTimestamp: userMessageTimestamp)
}
} catch {
guard self.isCurrentSession(sessionSnapshot) else { return }
self.removePendingLocalUserEcho(for: runId)
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
self.clearPendingRun(runId)
self.errorText = error.localizedDescription
self.logDiagnostic(
@@ -1724,48 +1717,6 @@ public final class OpenClawChatViewModel {
return "Chat failed"
}
private func finishPendingRunAfterTerminalOkSendAck(_ response: OpenClawChatSendResponse) {
self.clearPendingRun(response.runId)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.logDiagnostic(
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
+ "runId=\(response.runId) status=ok")
}
private func finishPendingRunIfTerminalSendAck(_ response: OpenClawChatSendResponse) -> Bool {
switch response.status {
case "timeout":
self.removePendingLocalUserEcho(for: response.runId)
self.clearPendingRun(response.runId)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.errorText = "Chat failed before the run started; try again."
self.logDiagnostic(
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
+ "runId=\(response.runId) status=timeout")
return true
case "error":
self.removePendingLocalUserEcho(for: response.runId)
self.clearPendingRun(response.runId)
self.pendingToolCallsById = [:]
self.streamingAssistantText = nil
self.errorText = "Chat failed before the run started; try again."
self.logDiagnostic(
"chat.ui send terminal ack sessionKey=\(self.sessionKey) "
+ "runId=\(response.runId) status=error")
return true
default:
return false
}
}
private func removePendingLocalUserEcho(for runId: String) {
guard let messageID = self.pendingLocalUserEchoMessageIDsByRunID[runId] else { return }
self.messages.removeAll { $0.id == messageID }
self.pendingLocalUserEchoMessageIDsByRunID[runId] = nil
}
private func armPostSendRefreshFallback(
runId: String,
sessionSnapshot: SessionSnapshot,

View File

@@ -21,15 +21,6 @@ private func chatErrorMessage(role: String, errorMessage: String, timestamp: Dou
])
}
fileprivate extension Array where Element == OpenClawChatMessage {
func containsUserText(_ text: String) -> Bool {
self.contains { message in
message.role == "user" &&
message.content.contains { $0.text == text }
}
}
}
private func historyPayload(
sessionKey: String = "main",
sessionId: String? = "sess-main",
@@ -113,7 +104,6 @@ private func makeViewModel(
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil,
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
healthResponses: [Bool] = [true],
initialThinkingLevel: String? = nil,
@@ -132,7 +122,6 @@ private func makeViewModel(
compactSessionHook: compactSessionHook,
setSessionModelHook: setSessionModelHook,
setSessionThinkingHook: setSessionThinkingHook,
sendMessageHook: sendMessageHook,
waitForRunCompletionHook: waitForRunCompletionHook,
healthResponses: healthResponses)
let vm = await MainActor.run {
@@ -358,7 +347,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
private let compactSessionHook: (@Sendable (String) async throws -> Void)?
private let setSessionModelHook: (@Sendable (String?) async throws -> Void)?
private let setSessionThinkingHook: (@Sendable (String) async throws -> Void)?
private let sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)?
private let waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)?
private let healthResponses: [Bool]
@@ -376,7 +364,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
compactSessionHook: (@Sendable (String) async throws -> Void)? = nil,
setSessionModelHook: (@Sendable (String?) async throws -> Void)? = nil,
setSessionThinkingHook: (@Sendable (String) async throws -> Void)? = nil,
sendMessageHook: (@Sendable (String) async throws -> OpenClawChatSendResponse)? = nil,
waitForRunCompletionHook: (@Sendable (String, Int) async -> Bool)? = nil,
healthResponses: [Bool] = [true])
{
@@ -390,7 +377,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
self.compactSessionHook = compactSessionHook
self.setSessionModelHook = setSessionModelHook
self.setSessionThinkingHook = setSessionThinkingHook
self.sendMessageHook = sendMessageHook
self.waitForRunCompletionHook = waitForRunCompletionHook
self.healthResponses = healthResponses
var cont: AsyncStream<OpenClawChatTransportEvent>.Continuation!
@@ -449,9 +435,6 @@ private final class TestChatTransport: @unchecked Sendable, OpenClawChatTranspor
await self.state.sentSessionKeysAppend(sessionKey)
await self.state.sentRunIdsAppend(idempotencyKey)
await self.state.sentThinkingLevelsAppend(thinking)
if let sendMessageHook {
return try await sendMessageHook(idempotencyKey)
}
return OpenClawChatSendResponse(runId: idempotencyKey, status: "ok")
}
@@ -897,50 +880,6 @@ struct ChatViewModelTests {
#expect(await MainActor.run { vm.input } == "second")
}
@Test func terminalOkSendAckClearsPendingRunWithoutWaitingForCompletion() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId, messages: [])
let (transport, vm) = await makeViewModel(historyResponses: [history, history])
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "cached")
try await waitUntil("terminal ok ack clears pending run") {
await MainActor.run { vm.pendingRunCount == 0 && !vm.isSending }
}
#expect(await MainActor.run { vm.errorText } == nil)
#expect(await transport.waitCompletionRunIds().isEmpty)
#expect(await MainActor.run { vm.messages.containsUserText("cached") })
}
@Test func terminalTimeoutSendAckSurfacesErrorAndAllowsNextSend() async throws {
let sessionId = "sess-main"
let history = historyPayload(sessionId: sessionId, messages: [])
let sendCount = AsyncCounter()
let (transport, vm) = await makeViewModel(
historyResponses: [history],
sendMessageHook: { runId in
let count = await sendCount.increment()
return OpenClawChatSendResponse(
runId: runId,
status: count == 1 ? "timeout" : "ok")
})
try await loadAndWaitBootstrap(vm: vm, sessionId: sessionId)
await sendUserMessage(vm, text: "first")
try await waitUntil("timeout ack clears pending run") {
await MainActor.run { vm.pendingRunCount == 0 && !vm.isSending }
}
#expect(await transport.sentRunIds().count == 1)
#expect(await MainActor.run { vm.errorText } == "Chat failed before the run started; try again.")
#expect(await MainActor.run { !vm.messages.containsUserText("first") })
await sendUserMessage(vm, text: "second")
try await waitUntil("second send is accepted after timeout ack") {
await transport.sentRunIds().count == 2
}
}
@Test func `keeps optimistic user message when final refresh returns only assistant history`() async throws {
let sessionId = "sess-main"
let now = Date().timeIntervalSince1970 * 1000

View File

@@ -138,7 +138,6 @@ const config = {
entry: rootEntries,
ignoreDependencies: [
"@openclaw/*",
"cross-spawn",
"file-type",
"playwright-core",
"sqlite-vec",

View File

@@ -1,4 +1,4 @@
ee542300a1f9d5c23e772d47f2acfcc92ee0a4da210974306790bf2220b80277 config-baseline.json
6349131baaa1828f2a071f42e4d7b17c8966c59b6588c8a4c1a32ea5ea4dcd5e config-baseline.core.json
de674ef01dad2828bb711a4648dc5a00f696f71c3c59004131d9475769bc1ff8 config-baseline.channel.json
ce2a731077f0f0135b7eaf01b00a60abfa0d2776aba4be237491d492af0c8a02 config-baseline.plugin.json
3ac3be8b7e201eb577854806a9806ba90acbfb2616e14b3ffd1169f188620303 config-baseline.json
2923c1120c0369aeca6646cd67f7264590c6a1f4e5bc3157a04d7661324c6868 config-baseline.core.json
769899651e2769833ae7e9c8fbf402e55f3d5e32da6bfe21a9659cc35d1f07bb config-baseline.channel.json
d2e2114f1cd43dc894fe1a4836677b42a2a5af825537d6c4a932da832d58a590 config-baseline.plugin.json

View File

@@ -1,2 +1,2 @@
8d38dc64627e6bfcebc25215d499a30841953799133dea16ffce902cf301273f plugin-sdk-api-baseline.json
d7927d51588fd006d743fc56cc22d779141b487f82655d88054d2be12f3093ff plugin-sdk-api-baseline.jsonl
05ce13ad6d2ef72af943a61a023e26f58d01e37a04f76e279a933df9b6aed05b plugin-sdk-api-baseline.json
628a6ac85acd5ed71236b07d5760e211b9c0698ea529d5b3101c20579926b0ea plugin-sdk-api-baseline.jsonl

View File

@@ -157,12 +157,6 @@ If stdout is non-empty, that text is the delivered result. If stdout is empty an
<ParamField path="--model" type="string">
Model override; uses the selected allowed model for the job.
</ParamField>
<ParamField path="--fallbacks" type="string">
Per-job fallback model list, for example `--fallbacks openrouter/gpt-4.1-mini,openai/gpt-5`. Pass `--fallbacks ""` for a strict run with no fallbacks.
</ParamField>
<ParamField path="--clear-fallbacks" type="boolean">
On `cron edit`, removes the per-job fallback override so the job follows configured fallback precedence. Cannot be combined with `--fallbacks`.
</ParamField>
<ParamField path="--clear-model" type="boolean">
On `cron edit`, removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if set, otherwise the agent/default model). Cannot be combined with `--model`.
</ParamField>
@@ -484,7 +478,7 @@ Model override note:
- API `cron.update` payload patches can set `model: null` to clear a stored job model override.
- `openclaw cron edit <job-id> --clear-model` clears that override from the CLI (same effect as the `model: null` patch) and cannot be combined with `--model`.
- Configured fallback chains still apply because cron `--model` is a job primary, not a session `/model` override.
- `openclaw cron add|edit --fallbacks ...` sets payload `fallbacks`, replacing configured fallbacks for that job; `--fallbacks ""` disables fallback and makes the run strict. `openclaw cron edit <job-id> --clear-fallbacks` clears the per-job override.
- Payload `fallbacks` replaces configured fallbacks for that job; `fallbacks: []` disables fallback and makes the run strict.
- A plain `--model` with no explicit or configured fallback list does not fall through to the agent primary as a silent extra retry target.
</Note>

View File

@@ -83,7 +83,7 @@ Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
- **TUI PTY** runs in the `checks-node-core-runtime-tui-pty` Linux Node shard for TUI changes. The shard runs `test/vitest/vitest.tui-pty.config.ts` with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1`, so it covers both the deterministic `TuiBackend` fixture lane and the slower `tui --local` smoke that mocks only the external model endpoint.
- **TUI PTY** is a focused workflow for TUI changes. It runs `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` on Linux Node 24 for `src/tui/**`, the watch harness, package script, lockfile, and workflow edits. The required lane uses a deterministic `TuiBackend` fixture; the slower `tui --local` smoke is opt-in with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` and mocks only the external model endpoint.
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
- **Windows Node checks** are scoped to Windows-specific process/path wrappers, npm/pnpm/UI runner helpers, package manager config, and the CI workflow surfaces that execute that lane; unrelated source, plugin, install-smoke, and test-only changes stay on the Linux Node lanes.
@@ -155,7 +155,7 @@ pnpm check:timed # same gate with per-stage timings
pnpm build:strict-smoke
pnpm check:architecture
pnpm test:gateway:watch-regression
OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1 node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts
pnpm test # vitest tests
pnpm test:changed # cheap smart changed Vitest targets
pnpm test:channels

View File

@@ -23,8 +23,7 @@ Related:
## Options
- `-m, --message <text>`: message body
- `--message-file <path>`: read the message body from a UTF-8 file
- `-m, --message <text>`: required message body
- `-t, --to <dest>`: recipient used to derive the session key
- `--session-key <key>`: explicit session key to use for routing
- `--session-id <id>`: explicit session id
@@ -46,7 +45,6 @@ Related:
```bash
openclaw agent --to +15555550123 --message "status update" --deliver
openclaw agent --agent ops --message "Summarize logs"
openclaw agent --agent ops --message-file ./task.md
openclaw agent --agent ops --model openai/gpt-5.4 --message "Summarize logs"
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"
openclaw agent --agent ops --session-key incident-42 --message "Summarize status"
@@ -58,7 +56,6 @@ openclaw agent --agent ops --message "Run locally" --local
## Notes
- Pass exactly one of `--message` or `--message-file`. `--message-file` preserves multiline file content after removing an optional UTF-8 BOM, and rejects files that are not valid UTF-8.
- Gateway mode falls back to the embedded agent when the Gateway request fails. Use `--local` to force embedded execution up front.
- `--local` still preloads the plugin registry first, so plugin-provided providers, tools, and channels stay available during embedded runs.
- `--local` and embedded fallback runs are treated as one-shot runs. Bundled MCP loopback resources and warm Claude stdio sessions opened for that local process are retired after the reply, so scripted invocations do not keep local child processes alive.

View File

@@ -170,7 +170,7 @@ Use `--due` when you want the manual command to run only if the job is currently
## Models
`cron add|edit --model <ref>` selects an allowed model for the job. `cron add|edit --fallbacks <list>` sets per-job fallback models, for example `--fallbacks openrouter/gpt-4.1-mini,openai/gpt-5`; pass `--fallbacks ""` for a strict run with no fallbacks. `cron edit <job-id> --clear-fallbacks` removes the per-job fallback override. `cron edit <job-id> --clear-model` removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if present, otherwise the agent/default model); it cannot be combined with `--model`.
`cron add|edit --model <ref>` selects an allowed model for the job. `cron edit <job-id> --clear-model` removes the per-job model override so the job follows normal cron model-selection precedence (a stored cron-session override if present, otherwise the agent/default model); it cannot be combined with `--model`.
<Warning>
If the model is not allowed or cannot be resolved, cron fails the run with an explicit validation error instead of falling back to the job's agent or default model selection.
@@ -180,7 +180,7 @@ Cron `--model` is a **job primary**, not a chat-session `/model` override. That
- Configured model fallbacks still apply when the selected job model fails.
- Per-job payload `fallbacks` replaces the configured fallback list when present.
- An empty per-job fallback list (`--fallbacks ""` or `fallbacks: []` in the job payload/API) makes the cron run strict.
- An empty per-job fallback list (`fallbacks: []` in the job payload/API) makes the cron run strict.
- When a job has `--model` but no fallback list is configured, OpenClaw passes an explicit empty fallback override so the agent primary is not appended as a hidden retry target.
- Local-provider preflight checks walk configured fallbacks before marking a cron run `skipped`.

View File

@@ -181,20 +181,12 @@ Fetch usage-cost summaries from session logs.
```bash
openclaw gateway usage-cost
openclaw gateway usage-cost --days 7
openclaw gateway usage-cost --agent work --json
openclaw gateway usage-cost --all-agents
openclaw gateway usage-cost --json
```
<ParamField path="--days <days>" type="number" default="30">
Number of days to include.
</ParamField>
<ParamField path="--agent <id>" type="string">
Scope the cost summary to one configured agent id.
</ParamField>
<ParamField path="--all-agents" type="boolean">
Aggregate the cost summary across all configured agents. Cannot be combined with `--agent`.
</ParamField>
### `gateway stability`

View File

@@ -167,7 +167,7 @@ surfaces, while Codex native hooks remain a separate lower-level Codex mechanism
- Agent runtime: `agents.defaults.timeoutSeconds` default 172800s (48 hours); enforced in `runEmbeddedAgent` abort timer.
- Cron runtime: isolated agent-turn `timeoutSeconds` is owned by cron. The scheduler starts that timer when execution begins, aborts the underlying run at the configured deadline, then runs bounded cleanup before recording the timeout so a stale child session cannot keep the lane stuck.
- Session liveness diagnostics: with diagnostics enabled, `diagnostics.stuckSessionWarnMs` classifies long `processing` sessions that have no observed reply, tool, status, block, or ACP progress. Active embedded runs, model calls, and tool calls report as `session.long_running`; owned silent model calls also stay `session.long_running` until `diagnostics.stuckSessionAbortMs` so slow or non-streaming providers are not reported as stalled too early. Active work with no recent progress reports as `session.stalled`; owned model calls switch to `session.stalled` at or after the abort threshold, and ownerless stale model/tool activity is not hidden as long-running. `session.stuck` is reserved for recoverable stale session bookkeeping, including idle queued sessions with stale ownerless model/tool activity. Stale session bookkeeping releases the affected session lane immediately after recovery gates pass; stalled embedded runs are abort-drained only after `diagnostics.stuckSessionAbortMs` (default: at least 5 minutes and 3x the warning threshold) so queued work can resume without cutting off merely slow runs. Recovery emits structured requested/completed outcomes, and diagnostic state is marked idle only if the same processing generation is still current. Repeated `session.stuck` diagnostics back off while the session remains unchanged.
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered cloud model runs with no explicit model or agent timeout use the same default idle watchdog; cron-triggered local or self-hosted model runs disable the implicit watchdog unless an explicit timeout is configured, so slow local providers should set `models.providers.<id>.timeoutSeconds`.
- Model idle timeout: OpenClaw aborts a model request when no response chunks arrive before the idle window. `models.providers.<id>.timeoutSeconds` extends this idle watchdog for slow local/self-hosted providers, but it is still bounded by any lower `agents.defaults.timeoutSeconds` or run-specific timeout because those control the whole agent run. Otherwise OpenClaw uses `agents.defaults.timeoutSeconds` when configured, capped at 120s by default. Cron-triggered runs with no explicit model or agent timeout disable the idle watchdog and rely on the cron outer timeout.
- Provider HTTP request timeout: `models.providers.<id>.timeoutSeconds` applies to that provider's model HTTP fetches, including connect, headers, body, SDK request timeout, total guarded-fetch abort handling, and model stream idle watchdog. Use this for slow local/self-hosted providers such as Ollama before raising the whole agent runtime timeout, and keep the agent/runtime timeout at least as high when the model request needs to run longer.
## Where things can end early

View File

@@ -73,10 +73,9 @@ pnpm openclaw qa run \
--output-dir .artifacts/qa-e2e/smoke-ci-profile-dispatch
```
Use `smoke-ci` for deterministic profile proof with mock model providers and
Crabline fake provider servers. Use `release` for Stable/LTS proof against live
channels. When a command also needs an OpenClaw root profile, put the root
profile before the QA command:
Use `smoke-ci` for deterministic no-live-service proof and `release` for the
Stable/LTS proof lane. When a command also needs an OpenClaw root profile, put
the root profile before the QA command:
```bash
pnpm openclaw --profile work qa run --qa-profile smoke-ci
@@ -198,10 +197,7 @@ witness video when `MANTIS_DISCORD_VIEWER_CHROME_PROFILE_DIR` or
environment. That viewer profile is only for visual capture; the pass/fail
decision still comes from the Discord REST oracle.
CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`.
Scheduled and default manual runs execute the fast Matrix profile with live
frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`.
Manual `matrix_profile=all` fans out into the five profile shards.
CI uses the same command surface in `.github/workflows/qa-live-transports-convex.yml`. Scheduled and default manual runs execute the fast Matrix profile with live frontier credentials, `--fast`, and `OPENCLAW_QA_MATRIX_NO_REPLY_WINDOW_MS=3000`. Manual `matrix_profile=all` fans out into the five profile shards so the exhaustive catalog can run in parallel while keeping one artifact directory per shard.
For transport-real Telegram, Discord, Slack, and WhatsApp smoke lanes:
@@ -861,10 +857,7 @@ provider names.
## Transport adapters
`qa-lab` owns a generic transport seam for YAML QA scenarios. `qa-channel` is
the synthetic default. `crabline` starts local provider-shaped servers and runs
OpenClaw's normal channel plugins against them. `live` is reserved for real
provider credentials and external channels.
`qa-lab` owns a generic transport seam for YAML QA scenarios. `qa-channel` is the first adapter on that seam, but the design target is wider: future real or synthetic channels should plug into the same suite runner instead of adding a transport-specific QA runner.
At the architecture level, the split is:
@@ -874,10 +867,10 @@ At the architecture level, the split is:
### Adding a channel
Adding a channel to the YAML QA system requires the channel implementation plus
a scenario pack that exercises the channel contract. For smoke CI coverage, add
the matching Crabline fake provider server and expose it through the `crabline`
driver.
Adding a channel to the YAML QA system requires exactly two things:
1. A transport adapter for the channel.
2. A scenario pack that exercises the channel contract.
Do not add a new top-level QA command root when the shared `qa-lab` host can own the flow.

View File

@@ -43,18 +43,7 @@ If a provider block is missing entirely (`channels.<provider>` absent), runtime
### Channel model overrides
Use `channels.modelByChannel` to pin specific channel IDs or direct-message peers to a model. Values accept `provider/model` or configured model aliases. The channel mapping applies when a session does not already have a model override (for example, set via `/model`).
For group/thread conversations, keys are channel-specific group IDs, topic IDs, or channel names. For direct-message (DM) conversations, keys are peer identifiers derived from the channel's sender identity (`nativeDirectUserId`, `origin.from`, `origin.to`, `OriginatingTo`, `From`, or `SenderId`). The exact key form depends on the channel:
| Channel | DM key form | Example |
| -------- | ------------------- | -------------------------------------------- |
| Slack | `user:U...` | `user:U12345` |
| Telegram | raw user ID | `123456789` |
| Discord | raw user ID | `987654321` |
| WhatsApp | phone number or JID | `15551234567` |
| Matrix | Matrix user ID | `@user:matrix.org` |
| Feishu | `feishu:ou_...` | `feishu:ou_a8b6cab7e945387de5f253775d9b4d85` |
Use `channels.modelByChannel` to pin specific channel IDs to a model. Values accept `provider/model` or configured model aliases. The channel mapping applies when a session does not already have a model override (for example, set via `/model`).
```json5
{
@@ -65,20 +54,16 @@ For group/thread conversations, keys are channel-specific group IDs, topic IDs,
},
slack: {
C1234567890: "openai/gpt-5.5",
"user:U12345": "openai/gpt-5.4-mini",
},
telegram: {
"-1001234567890": "openai/gpt-5.4-mini",
"-1001234567890:topic:99": "anthropic/claude-sonnet-4-6",
"123456789": "openai/gpt-4.1",
},
},
},
}
```
DM-specific keys only match in direct-message conversations; they do not affect group/thread routing.
### Channel defaults and heartbeat
Use `channels.defaults` for shared group-policy and heartbeat behavior across providers:

View File

@@ -259,7 +259,6 @@ RestartSec=5
TimeoutStopSec=30
TimeoutStartSec=30
SuccessExitStatus=0 143
OOMPolicy=continue
KillMode=control-group
[Install]

View File

@@ -210,12 +210,6 @@ These flags log through normal OpenClaw logging, so `openclaw logs --follow`
and the Control UI Logs tab show them. Without the flags, the same diagnostics
remain available at `debug` level.
`[model-fetch]` start and response metadata (provider, API, model, status,
latency, and request fields such as method, URL, timeout, proxy, and policy)
is always emitted at `info` level regardless of
`OPENCLAW_DEBUG_MODEL_TRANSPORT`, so basic model transport hygiene is visible
without debug flags.
### Trace correlation
File logs are JSONL. When a log call carries a valid diagnostic trace context,

View File

@@ -86,7 +86,6 @@ RestartSec=5
TimeoutStopSec=30
TimeoutStartSec=30
SuccessExitStatus=0 143
OOMPolicy=continue
KillMode=control-group
[Install]
@@ -131,11 +130,6 @@ cat /proc/<child-pid>/oom_score_adj
Expected value for covered children is `1000`. The Gateway process should keep
its normal score, usually `0`.
The recommended systemd unit also sets `OOMPolicy=continue`. This keeps the
Gateway unit alive when a transient child process is selected by the OOM killer;
the child command/session can fail and report its error without systemd marking
the entire gateway service failed and restarting all channels.
This does not replace normal memory tuning. If a VPS or container repeatedly
kills children, increase the memory limit, reduce concurrency, or add stronger
resource controls such as systemd `MemoryMax=` or container-level memory limits.

View File

@@ -515,7 +515,7 @@ API key auth, and dynamic model resolution.
- `openclaw/plugin-sdk/provider-model-shared` - `ProviderReplayFamily`, `buildProviderReplayFamilyHooks(...)`, and the raw replay builders (`buildOpenAICompatibleReplayPolicy`, `buildAnthropicReplayPolicyForModel`, `buildGoogleGeminiReplayPolicy`, `buildHybridAnthropicOrOpenAIReplayPolicy`). Also exports Gemini replay helpers (`sanitizeGoogleGeminiReplayHistory`, `resolveTaggedReasoningOutputMode`) and endpoint/model helpers (`resolveProviderEndpoint`, `normalizeProviderId`, `normalizeGooglePreviewModelId`).
- `openclaw/plugin-sdk/provider-stream` - `ProviderStreamFamily`, `buildProviderStreamFamilyHooks(...)`, `composeProviderStreamWrappers(...)`, plus the shared OpenAI/Codex wrappers (`createOpenAIAttributionHeadersWrapper`, `createOpenAIFastModeWrapper`, `createOpenAIServiceTierWrapper`, `createOpenAIResponsesContextManagementWrapper`, `createCodexNativeWebSearchWrapper`), DeepSeek V4 OpenAI-compatible wrapper (`createDeepSeekV4OpenAICompatibleThinkingWrapper`), Anthropic Messages thinking prefill cleanup (`createAnthropicThinkingPrefillPayloadWrapper`), plain-text tool-call compat (`createPlainTextToolCallCompatWrapper`), and shared proxy/provider wrappers (`createOpenRouterWrapper`, `createToolStreamWrapper`, `createMinimaxFastModeWrapper`).
- `openclaw/plugin-sdk/provider-stream-shared` - lightweight payload and event wrappers for hot provider paths, including `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPayloadPatchStreamWrapper`, `createPlainTextToolCallCompatWrapper`, `normalizeOpenAICompatibleReasoningPayload(...)`, and `setQwenChatTemplateThinking(...)`.
- `openclaw/plugin-sdk/provider-stream-shared` - lightweight payload and event wrappers for hot provider paths, including `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPayloadPatchStreamWrapper`, `createPlainTextToolCallCompatWrapper`, and `setQwenChatTemplateThinking(...)`.
- `openclaw/plugin-sdk/provider-tools` - `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks("deepseek" | "gemini" | "openai")`, and underlying provider schema helpers.
For Gemini-family providers, keep the reasoning-output mode aligned with

View File

@@ -164,7 +164,7 @@ and pairing-path families.
| `plugin-sdk/provider-tools` | `ProviderToolCompatFamily`, `buildProviderToolCompatFamilyHooks`, and DeepSeek/Gemini/OpenAI schema cleanup + diagnostics |
| `plugin-sdk/provider-usage` | Provider usage snapshot types, shared usage fetch helpers, and provider fetchers such as `fetchClaudeUsage` |
| `plugin-sdk/provider-stream` | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, plain-text tool-call compat, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, `normalizeOpenAICompatibleReasoningPayload`, `setQwenChatTemplateThinking`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
| `plugin-sdk/provider-stream-shared` | Public shared provider stream wrapper helpers including `composeProviderStreamWrappers`, `createOpenAICompatibleCompletionsThinkingOffWrapper`, `createPlainTextToolCallCompatWrapper`, `createPayloadPatchStreamWrapper`, `createToolStreamWrapper`, `setQwenChatTemplateThinking`, and Anthropic/DeepSeek/OpenAI-compatible stream utilities |
| `plugin-sdk/provider-transport-runtime` | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |
| `plugin-sdk/provider-onboard` | Onboarding config patch helpers |
| `plugin-sdk/global-singleton` | Process-local singleton/map/cache helpers |

View File

@@ -523,11 +523,7 @@ Legacy aliases still normalize to the canonical bundled ids:
`agents.defaults.models["xai/<model>"].params.tool_stream` to `false` to
disable it.
- The bundled xAI wrapper strips unsupported strict tool-schema flags and
reasoning *effort* payload keys before sending native xAI requests. Only
`grok-4.3` / `grok-4.3-*` advertise configurable reasoning effort; all
other reasoning-capable xAI models still request
`include: ["reasoning.encrypted_content"]` so prior encrypted reasoning
can be replayed on follow-up turns.
reasoning payload keys before sending native xAI requests.
- `web_search`, `x_search`, and `code_execution` are exposed as OpenClaw
tools. OpenClaw enables the specific xAI built-in it needs inside each tool
request instead of attaching all native tools to every chat turn.

View File

@@ -45,10 +45,6 @@ cp docs/reference/AGENTS.default.md ~/.openclaw/workspace/AGENTS.md
- Before changing config or schedulers (for example crontab, systemd units, nginx configs, or shell rc files), inspect existing state first and preserve/merge by default.
- Don't send partial/streaming replies to external messaging surfaces (only final replies).
## Existing solutions preflight
Before proposing or building a custom system, feature, workflow, tool, integration, or automation, do a brief check for open-source projects, maintained libraries, existing OpenClaw plugins, or free platforms that already solve it well enough. Prefer those when adequate. Build custom only when existing options are unsuitable, too expensive, unmaintained, unsafe, non-compliant, or the user explicitly asks for custom. Avoid paid-service recommendations unless the user explicitly approves spend. Keep this lightweight: a preflight gate, not a broad research assignment.
## Session start (required)
- Read `SOUL.md`, `USER.md`, and today+yesterday in `memory/`.

View File

@@ -162,7 +162,6 @@ Rules of thumb:
- **Reset** (`/new`, `/reset`) creates a new `sessionId` for that `sessionKey`.
- **Daily reset** (default 4:00 AM local time on the gateway host) creates a new `sessionId` on the next message after the reset boundary.
- **Idle expiry** (`session.reset.idleMinutes` or legacy `session.idleMinutes`) creates a new `sessionId` when a message arrives after the idle window. When daily + idle are both configured, whichever expires first wins.
- **Control UI reconnect resume** can preserve the currently visible session for one reconnect send when the Gateway receives the matching `sessionId` from an operator UI client. Ordinary stale sends still create a new `sessionId`.
- **System events** (heartbeat, cron wakeups, exec notifications, gateway bookkeeping) may mutate the session row but do not extend daily/idle reset freshness. Reset rollover discards queued system-event notices for the previous session before the fresh prompt is built.
- **Parent fork policy** uses OpenClaw's active branch when creating a thread or subagent fork. If that branch is too large, OpenClaw starts the child with isolated context instead of failing or inheriting unusable history. The sizing policy is automatic; legacy `session.parentForkMaxTokens` config is removed by `openclaw doctor --fix`.

View File

@@ -33,10 +33,6 @@ git commit -m "Add agent workspace"
- Don't run destructive commands unless explicitly asked.
- Be concise in chat; write longer output to files in this workspace.
## Existing solutions preflight
Before proposing or building a custom system, feature, workflow, tool, integration, or automation, do a brief check for open-source projects, maintained libraries, existing OpenClaw plugins, or free platforms that already solve it well enough. Prefer those when adequate. Build custom only when existing options are unsuitable, too expensive, unmaintained, unsafe, non-compliant, or the user explicitly asks for custom. Avoid paid-service recommendations unless the user explicitly approves spend. Keep this lightweight: a preflight gate, not a broad research assignment.
## Daily memory (recommended)
- Keep a short daily log at memory/YYYY-MM-DD.md (create memory/ if needed).

View File

@@ -66,10 +66,6 @@ Capture what matters. Decisions, context, things to remember. Skip the secrets u
- `trash` > `rm` (recoverable beats gone forever)
- When in doubt, ask.
## Existing Solutions Preflight
Before proposing or building a custom system, feature, workflow, tool, integration, or automation, do a brief check for open-source projects, maintained libraries, existing OpenClaw plugins, or free platforms that already solve it well enough. Prefer those when adequate. Build custom only when existing options are unsuitable, too expensive, unmaintained, unsafe, non-compliant, or the user explicitly asks for custom. Avoid paid-service recommendations unless the user explicitly approves spend. Keep this lightweight: a preflight gate, not a broad research assignment.
## External vs Internal
**Safe to do freely:**

View File

@@ -1,7 +1,7 @@
---
title: Claw Supervisor
summary: "Fleet supervision plan for Codex app-server sessions controlled by OpenClaw."
read_when:
description: Fleet supervision plan for Codex app-server sessions controlled by OpenClaw.
readWhen:
- Designing Codex fleet supervision
- Building OpenClaw tools that read, steer, or spawn Codex sessions
- Choosing between local, Cloudflare, and VPS deployment for supervised Codex

View File

@@ -22,15 +22,6 @@ programmatic delivery.
</Step>
<Step title="Send a multiline prompt from a file">
```bash
openclaw agent --agent ops --message-file ./task.md
```
This reads a valid UTF-8 file as the agent message body.
</Step>
<Step title="Target a specific agent or session">
```bash
# Target a specific agent
@@ -65,8 +56,7 @@ programmatic delivery.
| Flag | Description |
| ----------------------------- | ----------------------------------------------------------- |
| `--message \<text\>` | Inline message to send |
| `--message-file \<path\>` | Read the message from a valid UTF-8 file |
| `--message \<text\>` | Message to send (required) |
| `--to \<dest\>` | Derive session key from a target (phone, chat id) |
| `--session-key \<key\>` | Use an explicit session key |
| `--agent \<id\>` | Target a configured agent (uses its `main` session) |
@@ -86,8 +76,6 @@ programmatic delivery.
- By default, the CLI goes **through the Gateway**. Add `--local` to force the
embedded runtime on the current machine.
- Pass exactly one of `--message` or `--message-file`. File messages preserve
multiline content after removing an optional UTF-8 BOM.
- If the Gateway is unreachable, the CLI **falls back** to the local embedded run.
- Session selection: `--to` derives the session key (group/channel targets
preserve isolation; direct chats collapse to `main`).
@@ -114,9 +102,6 @@ openclaw agent --to +15555550123 --message "Trace logs" --verbose on --json
# Turn with thinking level
openclaw agent --session-id 1234 --message "Summarize inbox" --thinking medium
# Multiline prompt from a file
openclaw agent --agent ops --message-file ./task.md
# Exact session key
openclaw agent --session-key agent:ops:incident-42 --message "Summarize status"

View File

@@ -4389,7 +4389,7 @@ describe("active-memory plugin", () => {
sessionId: "s-empty-search-completed-output",
updatedAt: 0,
};
runEmbeddedAgent.mockImplementation(async (params: { sessionFile: string }) => {
runEmbeddedAgent.mockImplementationOnce(async (params: { sessionFile: string }) => {
await writeTranscriptJsonl(params.sessionFile, [
{
message: {

View File

@@ -2120,7 +2120,7 @@ describe("diagnostics-otel service", () => {
sessionKey: "session-key",
sessionId: "session-id",
provider: "anthropic",
model: "anthropic/claude-sonnet-4.6",
model: "claude-sonnet-4.6",
usage: {
input: 100,
output: 40,
@@ -2136,9 +2136,7 @@ describe("diagnostics-otel service", () => {
const modelUsageOptions = startedSpanOptions("openclaw.model.usage");
expect(modelUsageOptions?.attributes?.["gen_ai.operation.name"]).toBe("chat");
expect(modelUsageOptions?.attributes?.["gen_ai.system"]).toBe("anthropic");
expect(modelUsageOptions?.attributes?.["gen_ai.request.model"]).toBe(
"anthropic/claude-sonnet-4.6",
);
expect(modelUsageOptions?.attributes?.["gen_ai.request.model"]).toBe("claude-sonnet-4.6");
expect(modelUsageOptions?.attributes?.["gen_ai.usage.input_tokens"]).toBe(150);
expect(modelUsageOptions?.attributes?.["gen_ai.usage.output_tokens"]).toBe(40);
expect(modelUsageOptions?.attributes?.["gen_ai.usage.cache_read.input_tokens"]).toBe(30);
@@ -2165,8 +2163,8 @@ describe("diagnostics-otel service", () => {
runId: "run-1",
callId: "call-1",
sessionKey: "session-key",
provider: "anthropic",
model: "anthropic/claude-sonnet-4.6",
provider: "openai",
model: "gpt-5.4",
api: "openai-completions",
durationMs: 250,
});
@@ -2195,8 +2193,8 @@ describe("diagnostics-otel service", () => {
expect(genAiOperationDuration?.record).toHaveBeenCalledTimes(2);
expect(genAiOperationDuration?.record).toHaveBeenCalledWith(0.25, {
"gen_ai.operation.name": "text_completion",
"gen_ai.provider.name": "anthropic",
"gen_ai.request.model": "unknown",
"gen_ai.provider.name": "openai",
"gen_ai.request.model": "gpt-5.4",
});
expect(genAiOperationDuration?.record).toHaveBeenCalledWith(1.25, {
"gen_ai.operation.name": "generate_content",

View File

@@ -468,12 +468,7 @@ function assignGenAiSpanIdentityAttrs(
attrs["gen_ai.system"] = lowCardinalityAttr(input.provider);
}
if (input.model) {
// Span attributes carry the full model id; only metric labels need bounded cardinality
// (the gen_ai metrics below still use lowCardinalityAttr). The low-cardinality allowlist
// regex rejects "/", so provider-qualified ids like "anthropic/claude-sonnet-4.6" collapse
// to "unknown" on the SPAN — breaking model attribution in trace backends (e.g. Langfuse
// reads gen_ai.request.model). Keep the redacted raw model on the span.
attrs["gen_ai.request.model"] = redactSensitiveText(input.model.trim());
attrs["gen_ai.request.model"] = lowCardinalityAttr(input.model);
}
attrs["gen_ai.operation.name"] = genAiOperationName(input.api);
}

View File

@@ -72,9 +72,6 @@ export function createDiscordDraftPreviewController(params: {
let hasStreamedMessage = false;
let finalizedViaPreviewMessage = false;
let finalReplyDelivered = false;
// Final delivery cancels the compositor gate before Discord decides whether
// to edit the progress preview, so keep the pre-final eligibility bit.
let progressDraftStartedBeforeFinal = false;
const previewToolProgressEnabled =
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
const suppressDefaultToolProgressMessages =
@@ -135,13 +132,12 @@ export function createDiscordDraftPreviewController(params: {
return discordStreamMode === "progress";
},
get hasProgressDraftStarted() {
return progressDraft.hasStarted || progressDraftStartedBeforeFinal;
return progressDraft.hasStarted;
},
get finalizedViaPreviewMessage() {
return finalizedViaPreviewMessage;
},
markFinalReplyStarted() {
progressDraftStartedBeforeFinal ||= progressDraft.hasStarted;
progressDraft.markFinalReplyStarted();
},
markFinalReplyDelivered() {

View File

@@ -2627,8 +2627,7 @@ describe("DiscordVoiceManager", () => {
try {
agentCommandMock
.mockResolvedValueOnce({ payloads: [{ text: "first answer" }] })
.mockResolvedValueOnce({ payloads: [{ text: "second answer" }] })
.mockResolvedValueOnce({ payloads: [{ text: "third answer" }] });
.mockResolvedValueOnce({ payloads: [{ text: "second answer" }] });
const manager = createManager({
groupPolicy: "open",
voice: {
@@ -2639,7 +2638,6 @@ describe("DiscordVoiceManager", () => {
});
await manager.join({ guildId: "g1", channelId: "1001" });
const player = getLastAudioPlayer();
const entry = getSessionEntry(manager) as {
realtime?: {
beginSpeakerTurn: (
@@ -2681,19 +2679,6 @@ describe("DiscordVoiceManager", () => {
await vi.advanceTimersByTimeAsync(1_510);
expectUserMessageIncludes("second answer");
const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as
| (() => void)
| undefined;
idleHandler?.();
const thirdTurn = entry.realtime?.beginSpeakerTurn(
{ extraSystemPrompt: undefined, senderIsOwner: true, speakerLabel: "Owner" },
"u-owner",
);
thirdTurn?.sendInputAudio(Buffer.alloc(8));
bridgeParams?.onTranscript?.("user", "third question", true);
await vi.advanceTimersByTimeAsync(260);
expectUserMessageNotIncludes("third answer");
} finally {
vi.useRealTimers();
}

View File

@@ -1,5 +1,5 @@
// Discord plugin module implements realtime behavior.
import { PassThrough, pipeline } from "node:stream";
import { PassThrough } from "node:stream";
import type { DiscordAccountConfig, OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
asDateTimestampMs,
@@ -408,11 +408,8 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
| { message: string; suppressed: number; lastLoggedAt: number }
| undefined;
private readonly playerIdleHandler = () => {
const hadOutputAudio = this.isOutputAudioActive();
this.resetOutputStream("player-idle");
if (hadOutputAudio) {
this.completeExactSpeechResponse("player-idle");
}
this.completeExactSpeechResponse("player-idle");
};
constructor(
@@ -782,13 +779,9 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
return;
}
this.logOutputAudioStopped(reason);
this.clearOutputPlaybackWatchdog();
this.outputStream = null;
this.resetOutputAudioStats();
// The Opus resource can close without Discord emitting player idle. This
// close path releases queued exact speech, so clear the old watchdog before
// the next response owns exact-speech state.
this.completeExactSpeechResponse(reason);
this.completeExactSpeechResponse(reason, { drain: false });
}
private queueOutputAudio(stream: PassThrough, discordPcm: Buffer): void {
@@ -821,15 +814,7 @@ export class DiscordRealtimeVoiceSession implements VoiceRealtimeSession {
this.resetOutputStream("opus-encode-error");
});
opusStream.once("close", () => this.handleOutputStreamClosed(stream, "stream-close"));
pipeline(stream, opusStream, (err) => {
if (!err) {
return;
}
logger.warn(
`discord voice: realtime output pipeline failed guild=${this.params.entry.guildId} channel=${this.params.entry.channelId}: ${formatErrorMessage(err)}`,
);
this.resetOutputStream("output-pipeline-error");
});
stream.pipe(opusStream);
if (this.outputPacedBuffer.length > 0) {
stream.write(this.outputPacedBuffer);
this.outputPacedBuffer = Buffer.alloc(0);

View File

@@ -8,7 +8,6 @@ import type {
SpeechProviderConfig,
SpeechProviderOverrides,
SpeechProviderPlugin,
SpeechSynthesisRequest,
SpeechVoiceOption,
} from "openclaw/plugin-sdk/speech";
import {
@@ -390,40 +389,6 @@ async function listElevenLabsVoices(params: {
}
}
type ElevenLabsSynthesisRequest = Pick<
SpeechSynthesisRequest,
"providerConfig" | "providerOverrides" | "text" | "timeoutMs"
>;
function resolveElevenLabsTtsRequest(
req: ElevenLabsSynthesisRequest,
options: Pick<Parameters<typeof elevenLabsTTS>[0], "outputFormat" | "latencyTier">,
): Parameters<typeof elevenLabsTTS>[0] {
const config = readElevenLabsProviderConfig(req.providerConfig);
const overrides = req.providerOverrides ?? {};
const apiKey =
config.apiKey || resolveElevenLabsApiKeyWithProfileFallback() || process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
return {
text: req.text,
apiKey,
baseUrl: config.baseUrl,
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
modelId: normalizeElevenLabsTtsModelId(trimToUndefined(overrides.modelId)) ?? config.modelId,
outputFormat: options.outputFormat,
seed: normalizeElevenLabsSeed(overrides.seed) ?? config.seed,
applyTextNormalization:
(trimToUndefined(overrides.applyTextNormalization) as "auto" | "on" | "off" | undefined) ??
config.applyTextNormalization,
languageCode: trimToUndefined(overrides.languageCode) ?? config.languageCode,
latencyTier: options.latencyTier,
voiceSettings: resolveVoiceSettingsOverride(config.voiceSettings, overrides.voiceSettings),
timeoutMs: req.timeoutMs,
};
}
export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
return {
id: "elevenlabs",
@@ -544,16 +509,37 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
process.env.XI_API_KEY,
),
synthesize: async (req) => {
const config = readElevenLabsProviderConfig(req.providerConfig);
const overrides = req.providerOverrides ?? {};
const apiKey =
config.apiKey || resolveElevenLabsApiKeyWithProfileFallback() || process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
const outputFormat =
trimToUndefined(overrides.outputFormat) ??
(req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128");
const audioBuffer = await elevenLabsTTS(
resolveElevenLabsTtsRequest(req, {
outputFormat,
latencyTier: normalizeElevenLabsLatencyTier(overrides.latencyTier),
}),
);
const latencyTier = normalizeElevenLabsLatencyTier(overrides.latencyTier);
const audioBuffer = await elevenLabsTTS({
text: req.text,
apiKey,
baseUrl: config.baseUrl,
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
modelId:
normalizeElevenLabsTtsModelId(trimToUndefined(overrides.modelId)) ?? config.modelId,
outputFormat,
seed: normalizeElevenLabsSeed(overrides.seed) ?? config.seed,
applyTextNormalization:
(trimToUndefined(overrides.applyTextNormalization) as
| "auto"
| "on"
| "off"
| undefined) ?? config.applyTextNormalization,
languageCode: trimToUndefined(overrides.languageCode) ?? config.languageCode,
latencyTier,
voiceSettings: resolveVoiceSettingsOverride(config.voiceSettings, overrides.voiceSettings),
timeoutMs: req.timeoutMs,
});
return {
audioBuffer,
outputFormat,
@@ -562,16 +548,37 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
};
},
streamSynthesize: async (req) => {
const config = readElevenLabsProviderConfig(req.providerConfig);
const overrides = req.providerOverrides ?? {};
const apiKey =
config.apiKey || resolveElevenLabsApiKeyWithProfileFallback() || process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
const outputFormat =
trimToUndefined(overrides.outputFormat) ??
(req.target === "voice-note" ? "opus_48000_64" : "mp3_44100_128");
const stream = await elevenLabsTTSStream(
resolveElevenLabsTtsRequest(req, {
outputFormat,
latencyTier: normalizeElevenLabsLatencyTier(overrides.latencyTier),
}),
);
const latencyTier = normalizeElevenLabsLatencyTier(overrides.latencyTier);
const stream = await elevenLabsTTSStream({
text: req.text,
apiKey,
baseUrl: config.baseUrl,
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
modelId:
normalizeElevenLabsTtsModelId(trimToUndefined(overrides.modelId)) ?? config.modelId,
outputFormat,
seed: normalizeElevenLabsSeed(overrides.seed) ?? config.seed,
applyTextNormalization:
(trimToUndefined(overrides.applyTextNormalization) as
| "auto"
| "on"
| "off"
| undefined) ?? config.applyTextNormalization,
languageCode: trimToUndefined(overrides.languageCode) ?? config.languageCode,
latencyTier,
voiceSettings: resolveVoiceSettingsOverride(config.voiceSettings, overrides.voiceSettings),
timeoutMs: req.timeoutMs,
});
return {
audioStream: stream.audioStream,
outputFormat,
@@ -581,9 +588,34 @@ export function buildElevenLabsSpeechProvider(): SpeechProviderPlugin {
};
},
synthesizeTelephony: async (req) => {
const config = readElevenLabsProviderConfig(req.providerConfig);
const overrides = req.providerOverrides ?? {};
const apiKey =
config.apiKey || resolveElevenLabsApiKeyWithProfileFallback() || process.env.XI_API_KEY;
if (!apiKey) {
throw new Error("ElevenLabs API key missing");
}
const outputFormat = "pcm_22050";
const sampleRate = 22_050;
const audioBuffer = await elevenLabsTTS(resolveElevenLabsTtsRequest(req, { outputFormat }));
const audioBuffer = await elevenLabsTTS({
text: req.text,
apiKey,
baseUrl: config.baseUrl,
voiceId: trimToUndefined(overrides.voiceId) ?? config.voiceId,
modelId:
normalizeElevenLabsTtsModelId(trimToUndefined(overrides.modelId)) ?? config.modelId,
outputFormat,
seed: normalizeElevenLabsSeed(overrides.seed) ?? config.seed,
applyTextNormalization:
(trimToUndefined(overrides.applyTextNormalization) as
| "auto"
| "on"
| "off"
| undefined) ?? config.applyTextNormalization,
languageCode: trimToUndefined(overrides.languageCode) ?? config.languageCode,
voiceSettings: resolveVoiceSettingsOverride(config.voiceSettings, overrides.voiceSettings),
timeoutMs: req.timeoutMs,
});
return { audioBuffer, outputFormat, sampleRate };
},
};

View File

@@ -12,7 +12,6 @@ const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn());
const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn());
const loadWebMediaMock = vi.hoisted(() => vi.fn());
const runFfmpegMock = vi.hoisted(() => vi.fn());
const runFfprobeMock = vi.hoisted(() => vi.fn());
const fileCreateMock = vi.hoisted(() => vi.fn());
const imageCreateMock = vi.hoisted(() => vi.fn());
@@ -50,7 +49,6 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
return {
...actual,
runFfmpeg: runFfmpegMock,
runFfprobe: runFfprobeMock,
};
});
@@ -191,7 +189,6 @@ describe("sendMediaFeishu msg_type routing", () => {
await fs.writeFile(args.at(-1) ?? "", Buffer.from("opus-output"));
return "";
});
runFfprobeMock.mockResolvedValue("1.234\n");
});
it("suppresses reply text only for voice-intent or native voice media", () => {
@@ -237,53 +234,6 @@ describe("sendMediaFeishu msg_type routing", () => {
expect(callData<{ msg_type?: string }>(messageCreateMock).msg_type).toBe("audio");
});
it("includes audio duration in the Feishu file upload", async () => {
const audio = Buffer.from("opus");
runFfprobeMock.mockResolvedValueOnce("2.345\n");
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: audio,
fileName: "reply.ogg",
});
expect(runFfprobeMock).toHaveBeenCalledTimes(1);
const ffprobeArgs = mockCallArg<string[]>(runFfprobeMock, 0, 0);
expect(ffprobeArgs.slice(0, -1)).toEqual([
"-v",
"error",
"-show_entries",
"format=duration",
"-of",
"csv=p=0",
]);
expect(ffprobeArgs.at(-1)).toMatch(/input\.ogg$/);
expect(mockCallArg(runFfprobeMock, 0, 1)).toEqual({ timeoutMs: 5_000 });
expect(callData<{ duration?: number }>(fileCreateMock).duration).toBe(2345);
const messageData = callData<{ content?: string; msg_type?: string }>(messageCreateMock);
expect(messageData.msg_type).toBe("audio");
expect(JSON.parse(messageData.content ?? "{}")).toEqual({
file_key: "file_key_1",
});
});
it("omits audio duration when probing fails", async () => {
runFfprobeMock.mockRejectedValueOnce(new Error("ffprobe missing"));
await sendMediaFeishu({
cfg: emptyConfig,
to: "user:ou_target",
mediaBuffer: Buffer.from("opus"),
fileName: "reply.ogg",
});
expect(callData<{ duration?: number }>(fileCreateMock)).not.toHaveProperty("duration");
expect(JSON.parse(callData<{ content?: string }>(messageCreateMock).content ?? "{}")).toEqual({
file_key: "file_key_1",
});
});
it("uses msg_type=file for documents", async () => {
await sendMediaFeishu({
cfg: emptyConfig,

View File

@@ -5,11 +5,7 @@ import { Readable } from "node:stream";
import type * as Lark from "@larksuiteoapi/node-sdk";
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
import {
MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS,
runFfmpeg,
runFfprobe,
} from "openclaw/plugin-sdk/media-runtime";
import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-store";
import { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -475,7 +471,7 @@ export async function uploadFileFeishu(params: {
file: Buffer | string; // Buffer or file path
fileName: string;
fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
duration?: number; // Audio/video duration, in milliseconds.
duration?: number; // Required for audio/video files, in milliseconds
accountId?: string;
}): Promise<UploadFileResult> {
const { cfg, file, fileName, fileType, duration, accountId } = params;
@@ -496,7 +492,7 @@ export async function uploadFileFeishu(params: {
file_type: fileType,
file_name: safeFileName,
file: fileData,
...(duration !== undefined ? { duration } : {}),
...(duration !== undefined && { duration }),
},
}),
"Feishu file upload failed",
@@ -822,29 +818,6 @@ async function prepareFeishuVoiceMedia(params: {
}
}
async function probeAudioDurationMs(buffer: Buffer): Promise<number | undefined> {
try {
return await withTempWorkspace(
{ rootDir: resolvePreferredOpenClawTmpDir(), prefix: "feishu-audio-probe-" },
async (workspace) => {
const inputPath = await workspace.write("input.ogg", buffer);
const stdout = await runFfprobe(
["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", inputPath],
{ timeoutMs: 5_000 },
);
const seconds = Number.parseFloat(stdout.trim());
if (!Number.isFinite(seconds) || seconds <= 0) {
return undefined;
}
return Math.max(1, Math.round(seconds * 1000));
},
);
} catch (err) {
console.warn("[feishu] failed to probe audio duration; voice bubble will omit it:", err);
return undefined;
}
}
/**
* Upload and send media (image or file) from URL, local path, or buffer.
* When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
@@ -930,13 +903,11 @@ export async function sendMediaFeishu(params: {
...(voiceIntentDegradedToFile ? { voiceIntentDegradedToFile: true } : {}),
};
}
const durationMs = routing.msgType === "audio" ? await probeAudioDurationMs(buffer) : undefined;
const { fileKey } = await uploadFileFeishu({
cfg,
file: buffer,
fileName: name,
fileType: routing.fileType ?? "stream",
...(durationMs !== undefined ? { duration: durationMs } : {}),
accountId,
});
const result = await sendFileFeishu({

View File

@@ -35,15 +35,6 @@ const GOOGLE_GEMINI_TEXT_MODELS: ModelDefinitionConfig[] = [
contextWindow: 1_048_576,
maxTokens: 65_536,
},
{
id: "gemini-3.5-flash",
name: "Gemini 3.5 Flash",
reasoning: true,
input: ["text", "image"],
cost: GOOGLE_GEMINI_COST,
contextWindow: 1_048_576,
maxTokens: 65_536,
},
{
id: "gemini-3.1-pro-preview",
name: "Gemini 3.1 Pro Preview",

View File

@@ -17,7 +17,6 @@ const GEMINI_3_1_FLASH_LITE_PREFIX = "gemini-3.1-flash-lite";
const GEMINI_3_1_FLASH_PREFIX = "gemini-3.1-flash";
const GEMINI_3_FLASH_LITE_PREFIX = "gemini-3-flash-lite";
const GEMINI_3_FLASH_PREFIX = "gemini-3-flash";
const GEMINI_3_5_FLASH_PREFIX = "gemini-3.5-flash";
const GEMINI_PRO_LATEST_ID = "gemini-pro-latest";
const GEMINI_FLASH_LATEST_ID = "gemini-flash-latest";
const GEMINI_FLASH_LITE_LATEST_ID = "gemini-flash-lite-latest";
@@ -189,7 +188,6 @@ export function resolveGoogleGeminiForwardCompatModel(params: {
};
} else if (
lower.startsWith(GEMINI_3_1_FLASH_PREFIX) ||
lower.startsWith(GEMINI_3_5_FLASH_PREFIX) ||
lower.startsWith(GEMINI_3_FLASH_PREFIX) ||
lower === GEMINI_FLASH_LATEST_ID
) {

View File

@@ -12,7 +12,6 @@ import type {
SpeechProviderConfig,
SpeechProviderOverrides,
SpeechProviderPlugin,
SpeechSynthesisRequest,
} from "openclaw/plugin-sdk/speech-core";
import { asObject, trimToUndefined } from "openclaw/plugin-sdk/speech-core";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -538,34 +537,6 @@ async function synthesizeGoogleTtsPcm(params: {
throw lastError instanceof Error ? lastError : new Error(String(lastError));
}
type GoogleTtsSynthesisRequest = Pick<
SpeechSynthesisRequest,
"cfg" | "providerConfig" | "providerOverrides" | "text" | "timeoutMs"
>;
async function synthesizeConfiguredGoogleTts(req: GoogleTtsSynthesisRequest): Promise<Buffer> {
const config = readGoogleTtsProviderConfig(req.providerConfig);
const overrides = readGoogleTtsOverrides(req.providerOverrides);
const apiKey = resolveGoogleTtsApiKey({
cfg: req.cfg,
providerConfig: req.providerConfig,
});
if (!apiKey) {
throw new Error("Google API key missing");
}
return synthesizeGoogleTtsPcm({
text: req.text,
apiKey,
baseUrl: resolveGoogleTtsBaseUrl({ cfg: req.cfg, providerConfig: config }),
request: sanitizeConfiguredModelProviderRequest(req.cfg?.models?.providers?.google?.request),
model: normalizeGoogleTtsModel(overrides.model ?? config.model),
voiceName: normalizeGoogleTtsVoiceName(overrides.voiceName ?? config.voiceName),
audioProfile: overrides.audioProfile ?? config.audioProfile,
speakerName: overrides.speakerName ?? config.speakerName,
timeoutMs: req.timeoutMs,
});
}
export function buildGoogleSpeechProvider(): SpeechProviderPlugin {
return {
id: "google",
@@ -627,7 +598,28 @@ export function buildGoogleSpeechProvider(): SpeechProviderPlugin {
};
},
synthesize: async (req) => {
const pcm = await synthesizeConfiguredGoogleTts(req);
const config = readGoogleTtsProviderConfig(req.providerConfig);
const overrides = readGoogleTtsOverrides(req.providerOverrides);
const apiKey = resolveGoogleTtsApiKey({
cfg: req.cfg,
providerConfig: req.providerConfig,
});
if (!apiKey) {
throw new Error("Google API key missing");
}
const pcm = await synthesizeGoogleTtsPcm({
text: req.text,
apiKey,
baseUrl: resolveGoogleTtsBaseUrl({ cfg: req.cfg, providerConfig: config }),
request: sanitizeConfiguredModelProviderRequest(
req.cfg?.models?.providers?.google?.request,
),
model: normalizeGoogleTtsModel(overrides.model ?? config.model),
voiceName: normalizeGoogleTtsVoiceName(overrides.voiceName ?? config.voiceName),
audioProfile: overrides.audioProfile ?? config.audioProfile,
speakerName: overrides.speakerName ?? config.speakerName,
timeoutMs: req.timeoutMs,
});
if (req.target === "voice-note") {
return {
audioBuffer: await transcodeAudioBufferToOpus({
@@ -649,7 +641,28 @@ export function buildGoogleSpeechProvider(): SpeechProviderPlugin {
};
},
synthesizeTelephony: async (req) => {
const pcm = await synthesizeConfiguredGoogleTts(req);
const config = readGoogleTtsProviderConfig(req.providerConfig);
const overrides = readGoogleTtsOverrides(req.providerOverrides);
const apiKey = resolveGoogleTtsApiKey({
cfg: req.cfg,
providerConfig: req.providerConfig,
});
if (!apiKey) {
throw new Error("Google API key missing");
}
const pcm = await synthesizeGoogleTtsPcm({
text: req.text,
apiKey,
baseUrl: resolveGoogleTtsBaseUrl({ cfg: req.cfg, providerConfig: config }),
request: sanitizeConfiguredModelProviderRequest(
req.cfg?.models?.providers?.google?.request,
),
model: normalizeGoogleTtsModel(overrides.model ?? config.model),
voiceName: normalizeGoogleTtsVoiceName(overrides.voiceName ?? config.voiceName),
audioProfile: overrides.audioProfile ?? config.audioProfile,
speakerName: overrides.speakerName ?? config.speakerName,
timeoutMs: req.timeoutMs,
});
return {
audioBuffer: pcm,
outputFormat: "pcm",

View File

@@ -1,5 +1,4 @@
// Imessage plugin module implements echo cache behavior.
import { stripLeadingEchoTextCorruptionMarkers } from "./echo-text-corruption.js";
import { hasPersistedIMessageEcho } from "./persisted-echo-cache.js";
type SentMessageLookup = {
@@ -36,6 +35,20 @@ export type SentMessageCache = {
const SENT_MESSAGE_TEXT_TTL_MS = 4_000;
const SENT_MESSAGE_ID_TTL_MS = 60_000;
function isLeadingEchoTextCorruptionMarker(code: number): boolean {
return (
code === 0x0000 || code === 0xfeff || code === 0xfffd || code === 0xfffe || code === 0xffff
);
}
function stripLeadingEchoTextCorruptionMarkers(text: string): string {
let offset = 0;
while (offset < text.length && isLeadingEchoTextCorruptionMarker(text.charCodeAt(offset))) {
offset += 1;
}
return offset === 0 ? text : text.slice(offset);
}
function normalizeEchoTextKey(text: string | undefined): string | null {
if (!text) {
return null;

View File

@@ -1,19 +0,0 @@
// Imessage plugin shared helper: strip leading attributedBody corruption markers from echo text.
// The in-memory (echo-cache) and persisted (persisted-echo-cache) echo-dedupe paths must normalize
// identically, so a reflected own-message echo whose attributedBody decoded with a leading
// NUL/replacement marker still matches the clean stored send. Kept here (a leaf module with no
// imports) so the persisted path can reuse it without an echo-cache <-> persisted-echo-cache cycle.
function isLeadingEchoTextCorruptionMarker(code: number): boolean {
return (
code === 0x0000 || code === 0xfeff || code === 0xfffd || code === 0xfffe || code === 0xffff
);
}
export function stripLeadingEchoTextCorruptionMarkers(text: string): string {
let offset = 0;
while (offset < text.length && isLeadingEchoTextCorruptionMarker(text.charCodeAt(offset))) {
offset += 1;
}
return offset === 0 ? text : text.slice(offset);
}

View File

@@ -91,21 +91,6 @@ describe("iMessage sent-message echo cache", () => {
expect(cache.has("acct:imessage:+1555", { text: "Delayed\u0000echo reply" })).toBe(false);
});
it("matches a delayed reflected echo with leading corruption markers via the persisted cache", () => {
// The persisted 12h cache is the only matcher once the 4s in-memory text TTL expires, so it
// must strip leading attributedBody corruption markers exactly like the in-memory key (#93511).
const scope = "acct:imessage:+1555";
const markers = String.fromCharCode(0x0000, 0xfffd, 0xfffe, 0xffff, 0xfeff);
rememberPersistedIMessageEcho({ scope, text: "Delayed echo reply" });
expect(hasPersistedIMessageEcho({ scope, text: "Delayed echo reply" })).toBe(true);
expect(hasPersistedIMessageEcho({ scope, text: `${markers}Delayed echo reply` })).toBe(true);
// Leading-only: a mid-string marker stays distinct.
expect(
hasPersistedIMessageEcho({ scope, text: `Delayed${String.fromCharCode(0x0000)}echo reply` }),
).toBe(false);
});
it("matches by outbound message id and ignores placeholder ids", () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-02-25T00:00:00Z"));

View File

@@ -3,7 +3,6 @@ import { createHash } from "node:crypto";
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { getIMessageRuntime } from "../runtime.js";
import { stripLeadingEchoTextCorruptionMarkers } from "./echo-text-corruption.js";
type PersistedEchoEntry = {
scope: string;
@@ -27,14 +26,7 @@ export const IMESSAGE_SENT_ECHOES_MAX_ENTRIES = 256;
type PersistedEchoStore = PluginStateSyncKeyedStore<PersistedEchoEntry>;
function normalizeText(text: string | undefined): string | undefined {
if (!text) {
return undefined;
}
// Match the in-memory echo-cache key so a reflected echo with a leading attributedBody
// corruption marker still matches the clean stored send (the persisted sibling of #93511).
const normalized = stripLeadingEchoTextCorruptionMarkers(
text.replace(/\r\n?/g, "\n").trim(),
).trim();
const normalized = text?.replace(/\r\n?/g, "\n").trim();
return normalized || undefined;
}

View File

@@ -1,7 +1,6 @@
// Kilocode plugin module implements stream behavior.
import type { ProviderWrapStreamFnContext } from "openclaw/plugin-sdk/plugin-entry";
import { resolveProviderRequestHeaders } from "openclaw/plugin-sdk/provider-http";
import { normalizeOpenAICompatibleReasoningPayload } from "openclaw/plugin-sdk/provider-stream-shared";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-runtime";
const KILOCODE_FEATURE_HEADER = "X-KILOCODE-FEATURE";
@@ -10,12 +9,52 @@ const KILOCODE_FEATURE_ENV_VAR = "KILOCODE_FEATURE";
type ThinkLevel = NonNullable<ProviderWrapStreamFnContext["thinkingLevel"]>;
type ProviderStreamFn = NonNullable<ProviderWrapStreamFnContext["streamFn"]>;
type ReasoningEffort = "none" | "minimal" | "low" | "medium" | "high" | "xhigh";
function resolveKilocodeAppHeaders(): Record<string, string> {
const feature = process.env[KILOCODE_FEATURE_ENV_VAR]?.trim() || KILOCODE_FEATURE_DEFAULT;
return { [KILOCODE_FEATURE_HEADER]: feature };
}
function mapThinkingLevelToReasoningEffort(thinkingLevel: ThinkLevel): ReasoningEffort {
if (thinkingLevel === "off") {
return "none";
}
if (thinkingLevel === "adaptive") {
return "medium";
}
if (thinkingLevel === "max") {
return "xhigh";
}
return thinkingLevel;
}
function normalizeKilocodeReasoningPayload(
payloadObj: Record<string, unknown>,
thinkingLevel?: ThinkLevel,
): void {
delete payloadObj.reasoning_effort;
if (!thinkingLevel || thinkingLevel === "off") {
return;
}
const existingReasoning = payloadObj.reasoning;
if (
existingReasoning &&
typeof existingReasoning === "object" &&
!Array.isArray(existingReasoning)
) {
const reasoningObj = existingReasoning as Record<string, unknown>;
if (!("max_tokens" in reasoningObj) && !("effort" in reasoningObj)) {
reasoningObj.effort = mapThinkingLevelToReasoningEffort(thinkingLevel);
}
} else if (!existingReasoning) {
payloadObj.reasoning = {
effort: mapThinkingLevelToReasoningEffort(thinkingLevel),
};
}
}
function normalizeKilocodeStopPayload(payloadObj: Record<string, unknown>): void {
if (typeof payloadObj.stop === "string") {
payloadObj.stop = [payloadObj.stop];
@@ -83,7 +122,7 @@ export function createKilocodeStreamWrapper(
const payloadObj = asRecord(payload);
if (payloadObj) {
// Keep Kilo thinking defaults overrideable by later caller/config payload hooks.
normalizeOpenAICompatibleReasoningPayload(payloadObj, thinkingLevel);
normalizeKilocodeReasoningPayload(payloadObj, thinkingLevel);
}
const result = originalOnPayload?.(payload, payloadModel);

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