Fix voice-assistant agent routing and TTS escape sequence handling #4
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: CI | |
| on: | |
| push: | |
| branches: [main] | |
| pull_request: | |
| concurrency: | |
| group: ci-${{ github.event.pull_request.number || github.sha }} | |
| cancel-in-progress: true | |
| jobs: | |
| # Detect docs-only changes to skip heavy jobs (test, build, Windows, macOS, Android). | |
| # Lint and format always run. Fail-safe: if detection fails, run everything. | |
| docs-scope: | |
| runs-on: ubuntu-latest | |
| outputs: | |
| docs_only: ${{ steps.check.outputs.docs_only }} | |
| docs_changed: ${{ steps.check.outputs.docs_changed }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| submodules: false | |
| - name: Detect docs-only changes | |
| id: check | |
| uses: ./.github/actions/detect-docs-changes | |
| # Detect which heavy areas are touched so PRs can skip unrelated expensive jobs. | |
| # Push to main keeps broad coverage. | |
| changed-scope: | |
| needs: [docs-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' | |
| runs-on: ubuntu-latest | |
| outputs: | |
| run_node: ${{ steps.scope.outputs.run_node }} | |
| run_macos: ${{ steps.scope.outputs.run_macos }} | |
| run_android: ${{ steps.scope.outputs.run_android }} | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| submodules: false | |
| - name: Detect changed scopes | |
| id: scope | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| if [ "${{ github.event_name }}" = "push" ]; then | |
| BASE="${{ github.event.before }}" | |
| else | |
| BASE="${{ github.event.pull_request.base.sha }}" | |
| fi | |
| CHANGED="$(git diff --name-only "$BASE" HEAD 2>/dev/null || echo "UNKNOWN")" | |
| if [ "$CHANGED" = "UNKNOWN" ] || [ -z "$CHANGED" ]; then | |
| # Fail-safe: run broad checks if detection fails. | |
| echo "run_node=true" >> "$GITHUB_OUTPUT" | |
| echo "run_macos=true" >> "$GITHUB_OUTPUT" | |
| echo "run_android=true" >> "$GITHUB_OUTPUT" | |
| exit 0 | |
| fi | |
| run_node=false | |
| run_macos=false | |
| run_android=false | |
| has_non_docs=false | |
| has_non_native_non_docs=false | |
| while IFS= read -r path; do | |
| [ -z "$path" ] && continue | |
| case "$path" in | |
| docs/*|*.md|*.mdx) | |
| continue | |
| ;; | |
| *) | |
| has_non_docs=true | |
| ;; | |
| esac | |
| case "$path" in | |
| apps/macos/*|apps/ios/*|apps/shared/*|Swabble/*) | |
| run_macos=true | |
| ;; | |
| esac | |
| case "$path" in | |
| apps/android/*|apps/shared/*) | |
| run_android=true | |
| ;; | |
| esac | |
| case "$path" in | |
| src/*|test/*|extensions/*|packages/*|scripts/*|ui/*|.github/*|openclaw.mjs|package.json|pnpm-lock.yaml|pnpm-workspace.yaml|tsconfig*.json|vitest*.ts|tsdown.config.ts|.oxlintrc.json|.oxfmtrc.jsonc) | |
| run_node=true | |
| ;; | |
| esac | |
| case "$path" in | |
| apps/android/*|apps/ios/*|apps/macos/*|apps/shared/*|Swabble/*|appcast.xml) | |
| ;; | |
| *) | |
| has_non_native_non_docs=true | |
| ;; | |
| esac | |
| done <<< "$CHANGED" | |
| # If there are non-doc files outside native app trees, keep Node checks enabled. | |
| if [ "$run_node" = false ] && [ "$has_non_docs" = true ] && [ "$has_non_native_non_docs" = true ]; then | |
| run_node=true | |
| fi | |
| echo "run_node=${run_node}" >> "$GITHUB_OUTPUT" | |
| echo "run_macos=${run_macos}" >> "$GITHUB_OUTPUT" | |
| echo "run_android=${run_android}" >> "$GITHUB_OUTPUT" | |
| # Build dist once for Node-relevant changes and share it with downstream jobs. | |
| build-artifacts: | |
| needs: [docs-scope, changed-scope, code-analysis, check] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Setup Node environment | |
| uses: ./.github/actions/setup-node-env | |
| with: | |
| install-bun: "false" | |
| - name: Build dist | |
| run: pnpm build | |
| - name: Upload dist artifact | |
| uses: actions/upload-artifact@v4 | |
| with: | |
| name: dist-build | |
| path: dist/ | |
| retention-days: 1 | |
| # Validate npm pack contents after build (only on push to main, not PRs). | |
| release-check: | |
| needs: [docs-scope, build-artifacts] | |
| if: github.event_name == 'push' && needs.docs-scope.outputs.docs_only != 'true' | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Setup Node environment | |
| uses: ./.github/actions/setup-node-env | |
| with: | |
| install-bun: "false" | |
| - name: Download dist artifact | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: dist-build | |
| path: dist/ | |
| - name: Check release contents | |
| run: pnpm release:check | |
| checks: | |
| needs: [docs-scope, changed-scope, code-analysis, check] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - runtime: node | |
| task: test | |
| command: pnpm canvas:a2ui:bundle && pnpm test | |
| - runtime: node | |
| task: protocol | |
| command: pnpm protocol:check | |
| - runtime: bun | |
| task: test | |
| command: pnpm canvas:a2ui:bundle && bunx vitest run --config vitest.unit.config.ts | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Setup Node environment | |
| uses: ./.github/actions/setup-node-env | |
| - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) | |
| run: ${{ matrix.command }} | |
| # Types, lint, and format check. | |
| check: | |
| name: "check" | |
| needs: [docs-scope] | |
| if: needs.docs-scope.outputs.docs_only != 'true' | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Setup Node environment | |
| uses: ./.github/actions/setup-node-env | |
| - name: Check types and lint and oxfmt | |
| run: pnpm check | |
| # Validate docs (format, lint, broken links) only when docs files changed. | |
| check-docs: | |
| needs: [docs-scope] | |
| if: needs.docs-scope.outputs.docs_changed == 'true' | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Setup Node environment | |
| uses: ./.github/actions/setup-node-env | |
| - name: Check docs | |
| run: pnpm check:docs | |
| # Check for files that grew past LOC threshold in this PR (delta-only). | |
| # On push events, all steps are skipped and the job passes (no-op). | |
| # Heavy downstream jobs depend on this to fail fast on violations. | |
| code-analysis: | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| steps: | |
| - name: Checkout | |
| if: github.event_name == 'pull_request' | |
| uses: actions/checkout@v4 | |
| with: | |
| fetch-depth: 0 | |
| submodules: false | |
| - name: Setup Python | |
| if: github.event_name == 'pull_request' | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Fetch base branch | |
| if: github.event_name == 'pull_request' | |
| run: git fetch origin ${{ github.base_ref }}:refs/remotes/origin/${{ github.base_ref }} | |
| - name: Check code file sizes | |
| if: github.event_name == 'pull_request' | |
| run: | | |
| python scripts/analyze_code_files.py \ | |
| --compare-to origin/${{ github.base_ref }} \ | |
| --threshold 1000 \ | |
| --strict | |
| secrets: | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Setup Python | |
| uses: actions/setup-python@v5 | |
| with: | |
| python-version: "3.12" | |
| - name: Install detect-secrets | |
| run: | | |
| python -m pip install --upgrade pip | |
| python -m pip install detect-secrets==1.5.0 | |
| - name: Detect secrets | |
| run: | | |
| if ! detect-secrets scan --baseline .secrets.baseline; then | |
| echo "::error::Secret scanning failed. See docs/gateway/security.md#secret-scanning-detect-secrets" | |
| exit 1 | |
| fi | |
| checks-windows: | |
| needs: [docs-scope, changed-scope, build-artifacts, code-analysis, check] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_node == 'true') | |
| runs-on: blacksmith-4vcpu-windows-2025 | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=4096 | |
| # Keep total concurrency predictable on the 4 vCPU runner: | |
| # `scripts/test-parallel.mjs` runs some vitest suites in parallel processes. | |
| OPENCLAW_TEST_WORKERS: 2 | |
| defaults: | |
| run: | |
| shell: bash | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - runtime: node | |
| task: lint | |
| command: pnpm lint | |
| - runtime: node | |
| task: test | |
| command: pnpm canvas:a2ui:bundle && pnpm test | |
| - runtime: node | |
| task: protocol | |
| command: pnpm protocol:check | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Try to exclude workspace from Windows Defender (best-effort) | |
| shell: pwsh | |
| run: | | |
| $cmd = Get-Command Add-MpPreference -ErrorAction SilentlyContinue | |
| if (-not $cmd) { | |
| Write-Host "Add-MpPreference not available, skipping Defender exclusions." | |
| exit 0 | |
| } | |
| try { | |
| # Defender sometimes intercepts process spawning (vitest workers). If this fails | |
| # (eg hardened images), keep going and rely on worker limiting above. | |
| Add-MpPreference -ExclusionPath "$env:GITHUB_WORKSPACE" -ErrorAction Stop | |
| Add-MpPreference -ExclusionProcess "node.exe" -ErrorAction Stop | |
| Write-Host "Defender exclusions applied." | |
| } catch { | |
| Write-Warning "Failed to apply Defender exclusions, continuing. $($_.Exception.Message)" | |
| } | |
| - name: Download dist artifact (lint lane) | |
| if: matrix.task == 'lint' | |
| uses: actions/download-artifact@v4 | |
| with: | |
| name: dist-build | |
| path: dist/ | |
| - name: Verify dist artifact (lint lane) | |
| if: matrix.task == 'lint' | |
| run: | | |
| set -euo pipefail | |
| test -s dist/index.js | |
| test -s dist/plugin-sdk/index.js | |
| - name: Setup Node.js | |
| uses: actions/setup-node@v4 | |
| with: | |
| node-version: 22.x | |
| check-latest: true | |
| - name: Setup pnpm + cache store | |
| uses: ./.github/actions/setup-pnpm-store-cache | |
| with: | |
| pnpm-version: "10.23.0" | |
| cache-key-suffix: "node22" | |
| - name: Setup Bun | |
| uses: oven-sh/setup-bun@v2 | |
| with: | |
| bun-version: latest | |
| - name: Runtime versions | |
| run: | | |
| node -v | |
| npm -v | |
| bun -v | |
| pnpm -v | |
| - name: Capture node path | |
| run: echo "NODE_BIN=$(dirname \"$(node -p \"process.execPath\")\")" >> "$GITHUB_ENV" | |
| - name: Install dependencies | |
| env: | |
| CI: true | |
| run: | | |
| export PATH="$NODE_BIN:$PATH" | |
| which node | |
| node -v | |
| pnpm -v | |
| pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true || pnpm install --frozen-lockfile --ignore-scripts=false --config.engine-strict=false --config.enable-pre-post-scripts=true | |
| - name: Run ${{ matrix.task }} (${{ matrix.runtime }}) | |
| run: ${{ matrix.command }} | |
| # Consolidated macOS job: runs TS tests + Swift lint/build/test sequentially | |
| # on a single runner. GitHub limits macOS concurrent jobs to 5 per org; | |
| # running 4 separate jobs per PR (as before) starved the queue. One job | |
| # per PR allows 5 PRs to run macOS checks simultaneously. | |
| macos: | |
| needs: [docs-scope, changed-scope, code-analysis, check] | |
| if: github.event_name == 'pull_request' && needs.docs-scope.outputs.docs_only != 'true' && needs.changed-scope.outputs.run_macos == 'true' | |
| runs-on: macos-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Setup Node environment | |
| uses: ./.github/actions/setup-node-env | |
| with: | |
| install-bun: "false" | |
| # --- Run all checks sequentially (fast gates first) --- | |
| - name: TS tests (macOS) | |
| env: | |
| NODE_OPTIONS: --max-old-space-size=4096 | |
| run: pnpm test | |
| # --- Xcode/Swift setup --- | |
| - name: Select Xcode 26.1 | |
| run: | | |
| sudo xcode-select -s /Applications/Xcode_26.1.app | |
| xcodebuild -version | |
| - name: Install XcodeGen / SwiftLint / SwiftFormat | |
| run: brew install xcodegen swiftlint swiftformat | |
| - name: Show toolchain | |
| run: | | |
| sw_vers | |
| xcodebuild -version | |
| swift --version | |
| - name: Swift lint | |
| run: | | |
| swiftlint --config .swiftlint.yml | |
| swiftformat --lint apps/macos/Sources --config .swiftformat | |
| - name: Cache SwiftPM | |
| uses: actions/cache@v4 | |
| with: | |
| path: ~/Library/Caches/org.swift.swiftpm | |
| key: ${{ runner.os }}-swiftpm-${{ hashFiles('apps/macos/Package.resolved') }} | |
| restore-keys: | | |
| ${{ runner.os }}-swiftpm- | |
| - name: Swift build (release) | |
| run: | | |
| set -euo pipefail | |
| for attempt in 1 2 3; do | |
| if swift build --package-path apps/macos --configuration release; then | |
| exit 0 | |
| fi | |
| echo "swift build failed (attempt $attempt/3). Retrying…" | |
| sleep $((attempt * 20)) | |
| done | |
| exit 1 | |
| - name: Swift test | |
| run: | | |
| set -euo pipefail | |
| for attempt in 1 2 3; do | |
| if swift test --package-path apps/macos --parallel --enable-code-coverage --show-codecov-path; then | |
| exit 0 | |
| fi | |
| echo "swift test failed (attempt $attempt/3). Retrying…" | |
| sleep $((attempt * 20)) | |
| done | |
| exit 1 | |
| ios: | |
| if: false # ignore iOS in CI for now | |
| runs-on: macos-latest | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Select Xcode 26.1 | |
| run: | | |
| sudo xcode-select -s /Applications/Xcode_26.1.app | |
| xcodebuild -version | |
| - name: Install XcodeGen | |
| run: brew install xcodegen | |
| - name: Install SwiftLint / SwiftFormat | |
| run: brew install swiftlint swiftformat | |
| - name: Show toolchain | |
| run: | | |
| sw_vers | |
| xcodebuild -version | |
| swift --version | |
| - name: Generate iOS project | |
| run: | | |
| cd apps/ios | |
| xcodegen generate | |
| - name: iOS tests | |
| run: | | |
| set -euo pipefail | |
| RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult" | |
| DEST_ID="$( | |
| python3 - <<'PY' | |
| import json | |
| import subprocess | |
| import sys | |
| import uuid | |
| def sh(args: list[str]) -> str: | |
| return subprocess.check_output(args, text=True).strip() | |
| # Prefer an already-created iPhone simulator if it exists. | |
| devices = json.loads(sh(["xcrun", "simctl", "list", "devices", "-j"])) | |
| candidates: list[tuple[str, str]] = [] | |
| for runtime, devs in (devices.get("devices") or {}).items(): | |
| for dev in devs or []: | |
| if not dev.get("isAvailable"): | |
| continue | |
| name = str(dev.get("name") or "") | |
| udid = str(dev.get("udid") or "") | |
| if not udid or not name.startswith("iPhone"): | |
| continue | |
| candidates.append((name, udid)) | |
| candidates.sort(key=lambda it: (0 if "iPhone 16" in it[0] else 1, it[0])) | |
| if candidates: | |
| print(candidates[0][1]) | |
| sys.exit(0) | |
| # Otherwise, create one from the newest available iOS runtime. | |
| runtimes = json.loads(sh(["xcrun", "simctl", "list", "runtimes", "-j"])).get("runtimes") or [] | |
| ios = [rt for rt in runtimes if rt.get("platform") == "iOS" and rt.get("isAvailable")] | |
| if not ios: | |
| print("No available iOS runtimes found.", file=sys.stderr) | |
| sys.exit(1) | |
| def version_key(rt: dict) -> tuple[int, ...]: | |
| parts: list[int] = [] | |
| for p in str(rt.get("version") or "0").split("."): | |
| try: | |
| parts.append(int(p)) | |
| except ValueError: | |
| parts.append(0) | |
| return tuple(parts) | |
| ios.sort(key=version_key, reverse=True) | |
| runtime = ios[0] | |
| runtime_id = str(runtime.get("identifier") or "") | |
| if not runtime_id: | |
| print("Missing iOS runtime identifier.", file=sys.stderr) | |
| sys.exit(1) | |
| supported = runtime.get("supportedDeviceTypes") or [] | |
| iphones = [dt for dt in supported if dt.get("productFamily") == "iPhone"] | |
| if not iphones: | |
| print("No iPhone device types for iOS runtime.", file=sys.stderr) | |
| sys.exit(1) | |
| iphones.sort( | |
| key=lambda dt: ( | |
| 0 if "iPhone 16" in str(dt.get("name") or "") else 1, | |
| str(dt.get("name") or ""), | |
| ) | |
| ) | |
| device_type_id = str(iphones[0].get("identifier") or "") | |
| if not device_type_id: | |
| print("Missing iPhone device type identifier.", file=sys.stderr) | |
| sys.exit(1) | |
| sim_name = f"CI iPhone {uuid.uuid4().hex[:8]}" | |
| udid = sh(["xcrun", "simctl", "create", sim_name, device_type_id, runtime_id]) | |
| if not udid: | |
| print("Failed to create iPhone simulator.", file=sys.stderr) | |
| sys.exit(1) | |
| print(udid) | |
| PY | |
| )" | |
| echo "Using iOS Simulator id: $DEST_ID" | |
| xcodebuild test \ | |
| -project apps/ios/Clawdis.xcodeproj \ | |
| -scheme Clawdis \ | |
| -destination "platform=iOS Simulator,id=$DEST_ID" \ | |
| -resultBundlePath "$RESULT_BUNDLE_PATH" \ | |
| -enableCodeCoverage YES | |
| - name: iOS coverage summary | |
| run: | | |
| set -euo pipefail | |
| RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult" | |
| xcrun xccov view --report --only-targets "$RESULT_BUNDLE_PATH" | |
| - name: iOS coverage gate (43%) | |
| run: | | |
| set -euo pipefail | |
| RESULT_BUNDLE_PATH="$RUNNER_TEMP/Clawdis-iOS.xcresult" | |
| RESULT_BUNDLE_PATH="$RESULT_BUNDLE_PATH" python3 - <<'PY' | |
| import json | |
| import os | |
| import subprocess | |
| import sys | |
| target_name = "Clawdis.app" | |
| minimum = 0.43 | |
| report = json.loads( | |
| subprocess.check_output( | |
| ["xcrun", "xccov", "view", "--report", "--json", os.environ["RESULT_BUNDLE_PATH"]], | |
| text=True, | |
| ) | |
| ) | |
| target_coverage = None | |
| for target in report.get("targets", []): | |
| if target.get("name") == target_name: | |
| target_coverage = float(target["lineCoverage"]) | |
| break | |
| if target_coverage is None: | |
| print(f"Could not find coverage for target: {target_name}") | |
| sys.exit(1) | |
| print(f"{target_name} line coverage: {target_coverage * 100:.2f}% (min {minimum * 100:.2f}%)") | |
| if target_coverage + 1e-12 < minimum: | |
| sys.exit(1) | |
| PY | |
| android: | |
| needs: [docs-scope, changed-scope, code-analysis, check] | |
| if: needs.docs-scope.outputs.docs_only != 'true' && (github.event_name == 'push' || needs.changed-scope.outputs.run_android == 'true') | |
| runs-on: blacksmith-4vcpu-ubuntu-2404 | |
| strategy: | |
| fail-fast: false | |
| matrix: | |
| include: | |
| - task: test | |
| command: ./gradlew --no-daemon :app:testDebugUnitTest | |
| - task: build | |
| command: ./gradlew --no-daemon :app:assembleDebug | |
| steps: | |
| - name: Checkout | |
| uses: actions/checkout@v4 | |
| with: | |
| submodules: false | |
| - name: Setup Java | |
| uses: actions/setup-java@v4 | |
| with: | |
| distribution: temurin | |
| java-version: 21 | |
| - name: Setup Android SDK | |
| uses: android-actions/setup-android@v3 | |
| with: | |
| accept-android-sdk-licenses: false | |
| - name: Setup Gradle | |
| uses: gradle/actions/setup-gradle@v4 | |
| with: | |
| gradle-version: 8.11.1 | |
| - name: Install Android SDK packages | |
| run: | | |
| yes | sdkmanager --licenses >/dev/null | |
| sdkmanager --install \ | |
| "platform-tools" \ | |
| "platforms;android-36" \ | |
| "build-tools;36.0.0" | |
| - name: Run Android ${{ matrix.task }} | |
| working-directory: apps/android | |
| run: ${{ matrix.command }} |