Compare commits

..

1 Commits

Author SHA1 Message Date
Vincent Koc
806e796cc2 fix(install): keep llama cpp runtime required 2026-06-23 13:43:24 +08:00
286 changed files with 2610 additions and 18940 deletions

View File

@@ -146,7 +146,7 @@ Default guidance:
Default Completeness bands:
- `Clawesome` (95-100): complete across expected workflows, variants, and
- `Lovable` (95-100): complete across expected workflows, variants, and
recovery branches, with only minor polish gaps.
- `Stable` (80-95): the expected workflow set is broadly present, with only
bounded missing branches.
@@ -172,7 +172,7 @@ Default Completeness bands:
Bands:
- `Clawesome`: 95-100
- `Lovable`: 95-100
- `Stable`: 80-95
- `Beta`: 70-80
- `Alpha`: 50-70

View File

@@ -1,23 +0,0 @@
# OpenClaw Maturity Scorecard Agent
You are refreshing the OpenClaw maturity score source for a release scorecard.
Goal: use the `$claw-score` skill to refresh `qa/maturity-scores.yaml` for every active surface in `taxonomy.yaml`, using the current repository and the release evidence artifacts in `.artifacts/maturity-evidence`.
Allowed tracked paths:
- `qa/maturity-scores.yaml`
Hard limits:
- Do not edit generated docs, taxonomy, workflows, scripts, package metadata, lockfiles, tests, or application code.
- Do not render docs. The workflow renders docs after validating the score source.
- Keep the score source schema valid for QA Lab maturity score validation.
Required workflow:
1. Use the `$claw-score` skill before editing.
2. Read `taxonomy.yaml`, any existing maturity score file, and the release evidence artifacts.
3. Refresh scores for every active surface in `taxonomy.yaml`.
4. Run the QA Lab maturity score validation used by this repository.
5. If no defensible score update is possible, leave a valid `qa/maturity-scores.yaml` and explain the uncertainty in the final message.

View File

@@ -1177,9 +1177,7 @@ jobs:
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
strategy:
fail-fast: false
# The canonical main path waits for the admission debounce above, so
# modestly widen this large matrix without recreating registration bursts.
max-parallel: 16
max-parallel: 12
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
steps:
- name: Checkout

View File

@@ -22,6 +22,12 @@ on:
push:
branches:
- main
paths:
- ".github/actions/**"
- ".github/codeql/**"
- ".github/workflows/**"
- "packages/**"
- "src/**"
schedule:
- cron: "0 6 * * *"
@@ -49,32 +55,32 @@ jobs:
include:
- language: javascript-typescript
category: core-auth-secrets
runs_on: ubuntu-24.04
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-core-auth-secrets-critical-security.yml
- language: javascript-typescript
category: channel-runtime-boundary
runs_on: ubuntu-24.04
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
- language: javascript-typescript
category: network-ssrf-boundary
runs_on: ubuntu-24.04
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-network-ssrf-boundary-critical-security.yml
- language: javascript-typescript
category: mcp-process-tool-boundary
runs_on: ubuntu-24.04
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
- language: javascript-typescript
category: plugin-trust-boundary
runs_on: ubuntu-24.04
runs_on: blacksmith-4vcpu-ubuntu-2404
timeout_minutes: 25
config_file: ./.github/codeql/codeql-plugin-trust-boundary-critical-security.yml
- language: actions
category: actions
runs_on: ubuntu-24.04
runs_on: blacksmith-8vcpu-ubuntu-2404
timeout_minutes: 10
config_file: ./.github/codeql/codeql-actions-critical-security.yml
steps:

View File

@@ -490,7 +490,7 @@ jobs:
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
Write-Error "Invalid crabbox_id"
}
$actionsRoot = "C:\ProgramData\crabbox\actions"
$actionsRoot = Join-Path $HOME ".crabbox\actions"
New-Item -ItemType Directory -Force $actionsRoot | Out-Null
$state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
$envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
@@ -546,7 +546,7 @@ jobs:
if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
$minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
}
$stop = Join-Path "C:\ProgramData\crabbox\actions" "$env:CRABBOX_ID.stop"
$stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
$deadline = (Get-Date).AddMinutes($minutes)
while ((Get-Date) -lt $deadline) {
if (Test-Path $stop) {

View File

@@ -12,40 +12,6 @@ on:
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:
qa_evidence_run_id:
description: Optional workflow run id containing qa-evidence.json
required: false
default: ""
type: string
ref:
description: OpenClaw branch, tag, or SHA containing the maturity score source
required: true
type: string
expected_sha:
description: Optional full SHA that ref must resolve to
required: false
default: ""
type: string
secrets:
OPENAI_API_KEY:
description: OpenAI API key used by live QA profile scenarios
required: true
OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY:
description: Optional OpenAI API key used by maturity scorecard agent steps
required: false
GH_APP_PRIVATE_KEY:
description: Optional GitHub App private key for generated docs PR creation
required: false
GH_APP_PRIVATE_KEY_FALLBACK:
description: Optional fallback GitHub App private key for generated docs PR creation
required: false
permissions:
actions: read
@@ -77,25 +43,14 @@ jobs:
- 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
@@ -133,7 +88,6 @@ jobs:
uses: ./.github/workflows/qa-profile-evidence.yml
with:
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
qa_profile: all
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
@@ -264,67 +218,6 @@ jobs:
}
NODE
- name: Ensure maturity scorecard agent key exists
env:
OPENAI_API_KEY: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
run: |
set -euo pipefail
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
echo "Missing OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
exit 1
fi
- name: Run Codex maturity scorecard agent
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
env:
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
MATURITY_SCORES_PATH: qa/maturity-scores.yaml
MATURITY_TAXONOMY_PATH: taxonomy.yaml
with:
openai-api-key: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
prompt-file: .github/codex/prompts/maturity-scorecard-agent.md
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
effort: high
sandbox: workspace-write
safety-strategy: drop-sudo
- name: Enforce focused maturity score patch
run: |
set -euo pipefail
git restore --staged :/
allowed='^qa/maturity-scores\.yaml$'
bad_tracked="$(
git diff --name-only HEAD -- | while IFS= read -r path; do
if [[ ! "$path" =~ $allowed ]]; then
printf '%s\n' "$path"
fi
done
)"
if [[ -n "$bad_tracked" ]]; then
echo "Maturity scorecard agent touched forbidden tracked paths:"
printf '%s\n' "$bad_tracked"
exit 1
fi
bad_untracked="$(
git ls-files --others --exclude-standard | while IFS= read -r path; do
if [[ "$path" != "qa/maturity-scores.yaml" ]]; then
printf '%s\n' "$path"
fi
done
)"
if [[ -n "$bad_untracked" ]]; then
echo "Maturity scorecard agent created forbidden untracked paths:"
printf '%s\n' "$bad_untracked"
exit 1
fi
if [[ ! -f qa/maturity-scores.yaml ]]; then
echo "Maturity scorecard agent must produce qa/maturity-scores.yaml." >&2
exit 1
fi
- name: Validate maturity score sources
run: |
node --import tsx --input-type=module <<'NODE'
@@ -367,7 +260,6 @@ jobs:
--strict-inputs
- name: Create generated docs PR app token
if: ${{ github.event_name == 'workflow_dispatch' }}
id: app-token
continue-on-error: true
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
@@ -378,7 +270,7 @@ jobs:
permission-pull-requests: write
- name: Create generated docs PR fallback app token
if: ${{ github.event_name == 'workflow_dispatch' && steps.app-token.outcome == 'failure' }}
if: ${{ steps.app-token.outcome == 'failure' }}
id: app-token-fallback
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
with:
@@ -388,7 +280,6 @@ jobs:
permission-pull-requests: write
- name: Open generated docs PR
if: ${{ github.event_name == 'workflow_dispatch' }}
env:
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
@@ -400,7 +291,7 @@ jobs:
exit 1
fi
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
{
echo
echo "- Pull request: skipped; generated scorecard matches selected ref"
@@ -420,6 +311,9 @@ jobs:
git fetch --no-tags --depth=1 origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
git switch -C "$branch"
git add qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md
if git ls-files --error-unmatch docs/maturity-scores.yaml >/dev/null 2>&1 || [[ -e docs/maturity-scores.yaml ]]; then
git add docs/maturity-scores.yaml
fi
git commit -m "docs: update maturity scorecard"
git push --force-with-lease origin "$branch"

View File

@@ -767,20 +767,6 @@ jobs:
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
maturity_scorecard_release_checks:
name: Render maturity scorecard release docs
needs: [resolve_target]
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group)
permissions:
actions: read
contents: read
uses: ./.github/workflows/maturity-scorecard.yml
with:
ref: ${{ needs.resolve_target.outputs.ref }}
expected_sha: ${{ needs.resolve_target.outputs.revision }}
secrets:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
qa_lab_parity_lane_release_checks:
name: Run QA Lab parity lane (${{ matrix.lane }})
needs: [resolve_target]
@@ -867,7 +853,7 @@ jobs:
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -973,7 +959,7 @@ jobs:
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1145,7 +1131,7 @@ jobs:
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1255,13 +1241,13 @@ jobs:
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
- name: Upload runtime tool coverage artifacts
if: ${{ always() && steps.verify_runtime_parity_status.outputs.ready == 'true' }}
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/runtime-parity-standard-report/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
qa_live_matrix_release_checks:
name: Run QA Lab live Matrix lane
@@ -1341,7 +1327,7 @@ jobs:
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1481,7 +1467,7 @@ jobs:
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1621,7 +1607,7 @@ jobs:
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1764,7 +1750,7 @@ jobs:
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1904,7 +1890,7 @@ jobs:
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
path: .artifacts/qa-e2e/
retention-days: 14
if-no-files-found: error
if-no-files-found: warn
- name: Record advisory status
if: always()
@@ -1960,7 +1946,6 @@ jobs:
- docker_e2e_release_checks
- package_acceptance_release_checks
- qa_lab_parity_lane_release_checks
- maturity_scorecard_release_checks
- qa_lab_parity_report_release_checks
- qa_lab_runtime_parity_release_checks
- runtime_tool_coverage_release_checks
@@ -2046,7 +2031,6 @@ jobs:
"docker_e2e_release_checks=${{ needs.docker_e2e_release_checks.result }}" \
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
"maturity_scorecard_release_checks=${{ needs.maturity_scorecard_release_checks.result }}" \
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \

View File

@@ -1466,9 +1466,9 @@ jobs:
fi
- name: Upload postpublish evidence
if: ${{ always() && inputs.publish_openclaw_npm }}
if: ${{ always() }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
if-no-files-found: error
if-no-files-found: ignore

View File

@@ -66,5 +66,5 @@ jobs:
with:
name: opengrep-full-sarif
path: .opengrep-out/precise.sarif
if-no-files-found: error
if-no-files-found: warn
retention-days: 30

View File

@@ -97,5 +97,5 @@ jobs:
with:
name: opengrep-pr-diff-sarif
path: .opengrep-out/precise.sarif
if-no-files-found: error
if-no-files-found: warn
retention-days: 30

View File

@@ -243,9 +243,6 @@ jobs:
NODE_OPTIONS: --max-old-space-size=8192
run: node scripts/build-all.mjs qaRuntime
- name: Ensure Playwright Chromium
run: node scripts/ensure-playwright-chromium.mjs
- name: Run QA profile
id: run_profile
env:

View File

@@ -37,7 +37,6 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
#### Pull requests
- **PR #92154** fix(qqbot): gate private group commands and close strict command visibility gaps. Thanks @sliverp.
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.

View File

@@ -152,7 +152,6 @@ extension SettingsProTab {
}
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
self.applyNotificationStatus(notificationSettings.authorizationStatus)
self.registerForRemoteNotificationsIfEnrollmentReady()
let issueCount = SettingsDiagnostics.issueCount(
gatewayConnected: self.gatewayDiagnosticConnected,
@@ -418,7 +417,6 @@ extension SettingsProTab {
let status = settings.authorizationStatus
Task { @MainActor in
self.applyNotificationStatus(status)
self.registerForRemoteNotificationsIfEnrollmentReady()
}
}
}
@@ -439,7 +437,6 @@ extension SettingsProTab {
func requestNotificationAuthorizationFromSettings() {
guard !self.isRequestingNotificationAuthorization else { return }
PushEnrollmentConsent.markDisclosureAccepted()
self.isRequestingNotificationAuthorization = true
Task {
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
@@ -451,19 +448,12 @@ extension SettingsProTab {
await MainActor.run {
self.isRequestingNotificationAuthorization = false
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
guard granted else { return }
self.registerForRemoteNotificationsIfEnrollmentReady()
guard granted, self.notificationStatus.allowsNotifications else { return }
UIApplication.shared.registerForRemoteNotifications()
}
}
}
@MainActor
func registerForRemoteNotificationsIfEnrollmentReady() {
guard PushEnrollmentConsent.disclosureAccepted else { return }
guard self.notificationStatus.allowsNotifications else { return }
UIApplication.shared.registerForRemoteNotifications()
}
@MainActor
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
self.notificationStatus = SettingsNotificationStatus(status)

View File

@@ -4103,9 +4103,6 @@ extension NodeAppModel {
private func registerAPNsTokenIfNeeded() async {
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else {
return
}
guard self.gatewayConnected else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("gateway_offline")
@@ -4166,23 +4163,6 @@ extension NodeAppModel {
}
}
private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool {
guard PushEnrollmentConsent.disclosureAccepted else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
}
return false
}
let status = await self.notificationAuthorizationStatus()
guard Self.isNotificationAuthorizationAllowed(status) else {
if usesRelayTransport {
GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized")
}
return false
}
return true
}
private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
let response = try await self.operatorGateway.request(
method: "gateway.identity.get",
@@ -5146,10 +5126,6 @@ extension NodeAppModel {
self.setOperatorConnected(connected)
}
func _test_canPublishAPNsRegistration(usesRelayTransport: Bool = true) async -> Bool {
await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport)
}
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
self.makeWatchChatItems(from: raw)
}

View File

@@ -123,28 +123,8 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
let notificationCenter = UNUserNotificationCenter.current()
notificationCenter.delegate = self
ExecApprovalNotificationBridge.registerCategory(center: notificationCenter)
Task { @MainActor in
await self.registerForRemoteNotificationsIfEnrollmentReady(application)
}
return true
}
private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async {
guard PushEnrollmentConsent.disclosureAccepted else { return }
guard await Self.isNotificationAuthorizationAllowed() else { return }
application.registerForRemoteNotifications()
}
private static func isNotificationAuthorizationAllowed() async -> Bool {
let settings = await UNUserNotificationCenter.current().notificationSettings()
switch settings.authorizationStatus {
case .authorized, .provisional, .ephemeral:
return true
case .denied, .notDetermined:
return false
@unknown default:
return false
}
return true
}
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {

View File

@@ -1,19 +0,0 @@
import Foundation
enum PushEnrollmentConsent {
static let disclosureAcceptedKey = "push.enrollment.disclosureAccepted"
static var disclosureAccepted: Bool {
UserDefaults.standard.bool(forKey: disclosureAcceptedKey)
}
static func markDisclosureAccepted() {
UserDefaults.standard.set(true, forKey: self.disclosureAcceptedKey)
}
#if DEBUG
static func reset() {
UserDefaults.standard.removeObject(forKey: self.disclosureAcceptedKey)
}
#endif
}

View File

@@ -76,7 +76,6 @@ Sources/Permissions/PermissionRequestBridge.swift
Sources/Push/ExecApprovalNotificationBridge.swift
Sources/Push/BackgroundAliveBeacon.swift
Sources/Push/PushBuildConfig.swift
Sources/Push/PushEnrollmentConsent.swift
Sources/Push/PushRegistrationManager.swift
Sources/Push/PushRelayClient.swift
Sources/Push/PushRelayKeychainStore.swift

View File

@@ -1377,24 +1377,6 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
#expect(center.addCalls == 1)
}
@Test @MainActor func apnsRegistrationRequiresDisclosureAndNotificationAuthorization() async {
let center = MockBootstrapNotificationCenter()
center.status = .authorized
let appModel = NodeAppModel(notificationCenter: center)
PushEnrollmentConsent.reset()
defer { PushEnrollmentConsent.reset() }
#expect(await appModel._test_canPublishAPNsRegistration() == false)
#expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false) == false)
PushEnrollmentConsent.markDisclosureAccepted()
center.status = .notDetermined
#expect(await appModel._test_canPublishAPNsRegistration() == false)
center.status = .authorized
#expect(await appModel._test_canPublishAPNsRegistration())
}
@Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws {
let center = MockBootstrapNotificationCenter()
center.status = .notDetermined

View File

@@ -550,20 +550,6 @@ struct RootTabsSourceGuardTests {
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
}
@Test func `push enrollment stays behind notification disclosure flow`() throws {
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
#expect(appSource.contains("PushEnrollmentConsent.disclosureAccepted"))
#expect(appSource.contains("await Self.isNotificationAuthorizationAllowed()"))
#expect(actionsSource.contains("PushEnrollmentConsent.markDisclosureAccepted()"))
#expect(actionsSource.contains("self.registerForRemoteNotificationsIfEnrollmentReady()"))
#expect(modelSource.contains("PushEnrollmentConsent.disclosureAccepted"))
#expect(modelSource.contains("notifications_not_authorized"))
#expect(modelSource.contains("enrollment_disclosure_not_accepted"))
}
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
@@ -800,13 +786,6 @@ struct RootTabsSourceGuardTests {
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
}
private static func openClawAppSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()
.deletingLastPathComponent()
.appendingPathComponent("Sources/OpenClawApp.swift")
}
private static func notificationPermissionGuidanceDialogSourceURL() -> URL {
URL(fileURLWithPath: #filePath)
.deletingLastPathComponent()

View File

@@ -15,12 +15,13 @@
import Foundation
import XCTest
@MainActor
var deviceLanguage = ""
var locale = ""
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
}
@MainActor
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
if waitForLoadingIndicator {
Snapshot.snapshot(name)
@@ -32,7 +33,6 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
/// - Parameters:
/// - name: The name of the snapshot
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
@MainActor
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
}
@@ -52,7 +52,6 @@ enum SnapshotError: Error, CustomDebugStringConvertible {
}
@objcMembers
@MainActor
open class Snapshot: NSObject {
static var app: XCUIApplication?
static var waitForAnimations = true
@@ -60,8 +59,6 @@ open class Snapshot: NSObject {
static var screenshotsDirectory: URL? {
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
}
static var deviceLanguage = ""
static var currentLocale = ""
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
@@ -106,17 +103,17 @@ open class Snapshot: NSObject {
do {
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
} catch {
NSLog("Couldn't detect/set locale...")
}
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
currentLocale = Locale(identifier: deviceLanguage).identifier
if locale.isEmpty && !deviceLanguage.isEmpty {
locale = Locale(identifier: deviceLanguage).identifier
}
if !currentLocale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
if !locale.isEmpty {
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
}
}
@@ -168,7 +165,7 @@ open class Snapshot: NSObject {
}
let screenshot = XCUIScreen.main.screenshot()
#if os(iOS) && !targetEnvironment(macCatalyst)
#if os(iOS)
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
#else
let image = screenshot.image
@@ -184,7 +181,7 @@ open class Snapshot: NSObject {
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
#if swift(<5.0)
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
#else
try image.pngData()?.write(to: path, options: .atomic)
#endif
@@ -284,7 +281,6 @@ private extension XCUIElementQuery {
return self.containing(isNetworkLoadingIndicator)
}
@MainActor
var deviceStatusBars: XCUIElementQuery {
guard let app = Snapshot.app else {
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
@@ -310,4 +306,4 @@ private extension CGFloat {
// Please don't remove the lines below
// They are used to detect outdated configuration files
// SnapshotHelperVersion [1.30]
// SnapshotHelperVersion [1.27]

View File

@@ -10,24 +10,7 @@ default_platform(:ios)
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
{
label: "iPhone",
patterns: [
/\AiPhone .* Pro Max\z/,
/\AiPhone .* Plus\z/,
/\AiPhone .*\z/
]
},
{
label: "13-inch iPad",
patterns: [
/\AiPad Pro 13-inch/,
/\AiPad Air 13-inch/,
/\AiPad .*13-inch/
]
}
].freeze
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
@@ -94,23 +77,11 @@ end
def snapshot_devices
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
return default_snapshot_devices if raw.empty?
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
raw.split(",").map(&:strip).reject(&:empty?)
end
def default_snapshot_devices
names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq
DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
match = family.fetch(:patterns).filter_map do |pattern|
names.find { |name| name.match?(pattern) }
end.first
UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil?
match
end
end
def watch_snapshot_device
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
@@ -142,51 +113,6 @@ def resolve_simulator_device(name)
fallback
end
def install_ready_for_review_edit_state_lookup!
require "spaceship"
app_class = Spaceship::ConnectAPI::App
app_class.class_eval do
unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review)
alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version
end
unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review)
alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info
end
def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES)
version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes)
return version if version
# First public releases can leave the only version in READY_FOR_REVIEW.
# Fastlane 2.236.1 excludes that state and then tries to create an illegal
# second version; use the existing review-ready version as the edit target.
client ||= Spaceship::ConnectAPI
platform ||= Spaceship::ConnectAPI::Platform::IOS
filter = {
appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW,
platform: platform
}
get_app_store_versions(client: client, filter: filter, includes: includes)
.sort_by { |candidate| Gem::Version.new(candidate.version_string) }
.last
end
def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES)
app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes)
return app_info if app_info
client ||= Spaceship::ConnectAPI
client
.get_app_infos(app_id: id, includes: includes)
.to_models
.find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW }
end
end
end
def bundle_identifier_for_product(product_path)
info_plist_path = File.join(product_path, "Info.plist")
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
@@ -284,7 +210,6 @@ def normalize_watch_screenshot_status_bar(path)
script = <<~SWIFT
import AppKit
import Foundation
import ImageIO
let path = CommandLine.arguments[1]
let timeText = CommandLine.arguments[2]
@@ -296,37 +221,36 @@ def normalize_watch_screenshot_status_bar(path)
exit(2)
}
let width = cgImage.width
let height = cgImage.height
let drawWidth = CGFloat(width)
let drawHeight = CGFloat(height)
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
guard let bitmapContext = CGContext(
data: nil,
width: width,
height: height,
bitsPerComponent: 8,
bytesPerRow: width * 4,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
let width = CGFloat(cgImage.width)
let height = CGFloat(cgImage.height)
guard let bitmap = NSBitmapImageRep(
bitmapDataPlanes: nil,
pixelsWide: Int(width),
pixelsHigh: Int(height),
bitsPerSample: 8,
samplesPerPixel: 4,
hasAlpha: true,
isPlanar: false,
colorSpaceName: .deviceRGB,
bytesPerRow: 0,
bitsPerPixel: 0),
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
else {
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
exit(3)
}
let graphicsContext = NSGraphicsContext(cgContext: bitmapContext, flipped: false)
bitmap.size = NSSize(width: width, height: height)
NSGraphicsContext.saveGraphicsState()
NSGraphicsContext.current = graphicsContext
NSColor.black.setFill()
NSBezierPath(rect: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight)).fill()
source.draw(
in: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
from: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
operation: .sourceOver,
in: NSRect(x: 0, y: 0, width: width, height: height),
from: NSRect(x: 0, y: 0, width: width, height: height),
operation: .copy,
fraction: 1.0)
NSColor.black.setFill()
NSBezierPath(rect: NSRect(x: drawWidth - 146, y: drawHeight - 92, width: 124, height: 70)).fill()
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
let paragraphStyle = NSMutableParagraphStyle()
paragraphStyle.alignment = .right
@@ -336,26 +260,17 @@ def normalize_watch_screenshot_status_bar(path)
.paragraphStyle: paragraphStyle,
]
timeText.draw(
in: NSRect(x: drawWidth - 134, y: drawHeight - 82, width: 102, height: 44),
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
withAttributes: attributes)
NSGraphicsContext.restoreGraphicsState()
guard let output = bitmapContext.makeImage(),
let destination = CGImageDestinationCreateWithURL(
URL(fileURLWithPath: path) as CFURL,
"public.png" as CFString,
1,
nil)
guard let png = bitmap.representation(using: .png, properties: [:])
else {
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
exit(4)
}
CGImageDestinationAddImage(destination, output, nil)
guard CGImageDestinationFinalize(destination) else {
fputs("Failed to write normalized screenshot at \\(path)\\n", stderr)
exit(5)
}
try png.write(to: URL(fileURLWithPath: path))
SWIFT
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
@@ -1046,7 +961,6 @@ platform :ios do
desc "Upload App Store metadata (and optionally screenshots)"
lane :metadata do
install_ready_for_review_edit_state_lookup!
sync_ios_versioning!
version_metadata = read_ios_version_metadata
api_key = app_store_connect_api_key_config

View File

@@ -104,7 +104,7 @@ Generate deterministic App Store screenshots:
pnpm ios:screenshots
```
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it chooses one available large iPhone simulator and one available 13-inch iPad simulator from the installed Xcode runtime; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
Upload to App Store Connect:

View File

@@ -2,9 +2,10 @@ project("OpenClaw.xcodeproj")
scheme("OpenClawUITests")
configuration("Debug")
# The Fastfile screenshot lane resolves concrete device names from the installed
# Xcode simulators. Fastlane validates Snapfile devices before lane overrides, so
# this file intentionally does not hardcode simulator model names.
devices([
"iPhone 16 Pro Max",
"iPad Pro 13-inch (M4)",
])
languages([
"en-US",

View File

@@ -1,3 +1,4 @@
// Bundled A2UI runtime resource embedded by OpenClawKit.
var __defProp$1 = Object.defineProperty;
var __exportAll = (all, no_symbols) => {
let target = {};
@@ -11935,10 +11936,6 @@ var __runInitializers = function(thisArg, initializers, value) {
};
return _classThis;
})();
/**
* Canvas A2UI browser bootstrap that installs theme overrides and native bridge
* helpers.
*/
const modalStyles = i$10`
dialog {
position: fixed;

View File

@@ -155,7 +155,6 @@ Notes:
- `onchar` still responds to explicit @mentions.
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
- After the bot sends a visible reply in a channel thread, later messages in that same thread are answered without a new @mention or `onchar` prefix, so multi-turn thread conversations keep flowing. Participation is remembered for 7 days of thread inactivity (refreshed on each reply) and persists across gateway restarts. Threads the bot has only observed are unaffected; start a new top-level message to require an explicit mention again.
## Threading and sessions

View File

@@ -151,7 +151,6 @@ to a group, then mention it or configure the group to run without a mention.
groups: {
"*": {
requireMention: true,
commandLevel: "all",
historyLimit: 50,
tools: { deny: ["exec", "read", "write"] },
},
@@ -159,7 +158,6 @@ to a group, then mention it or configure the group to run without a mention.
name: "Release room",
requireMention: false,
ignoreOtherMentions: true,
commandLevel: "safety",
historyLimit: 20,
prompt: "Keep replies short and operational.",
},
@@ -174,9 +172,6 @@ to a group, then mention it or configure the group to run without a mention.
settings include:
- `requireMention`: require an @mention before the bot replies. Default: `true`.
- `commandLevel`: control which built-in slash commands can run in groups.
Default: `all`, which preserves the pre-existing QQBot group behavior when the
setting is omitted.
- `ignoreOtherMentions`: drop messages that mention someone else but not the bot.
- `historyLimit`: keep recent non-mention group messages as context for the next mentioned turn. Set `0` to disable.
- `tools`: allow/deny tools for the whole group.
@@ -184,17 +179,6 @@ settings include:
- `name`: friendly label used in logs and group context.
- `prompt`: per-group behavior prompt appended to the agent context.
`commandLevel` accepts:
- `all`: keep recognized built-in commands available as before. Some commands may
stay hidden from menus, but authorized users can still run them in the group.
- `safety`: allow common collaboration commands such as `/help`, `/btw`, and
`/stop`; ask users to run sensitive commands such as `/config`, `/tools`, and
`/bash` in private chat.
- `strict`: only allow the group-session controls needed for strict group
operation. `/stop` still stays urgent so an authorized sender can interrupt an
active run.
Old QQBot `toolPolicy` entries are retired. Run `openclaw doctor --fix` to migrate them to `tools`.
Activation modes are `mention` and `always`. `requireMention: true` maps to

View File

@@ -198,7 +198,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured
## Full Release Validation
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, maturity scorecard rendering from QA profile evidence, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
See [Full release validation](/reference/full-release-validation) for the
stage matrix, exact workflow job names, profile differences, artifacts, and

View File

@@ -154,11 +154,6 @@ openclaw skills workshop reject <proposal-id> --reason "Duplicate"
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
```
`propose-create` always targets a new sibling skill under `skills/<name>/` and
rejects drafts that reference existing workspace skill paths such as
`skills/qa-check/SKILL.md`. Use `propose-update <skill>` once per existing skill
when a change touches current skills.
## Related
- [CLI reference](/cli)

View File

@@ -68,14 +68,6 @@
"source": "/reference/openclaw-sdk-api-design",
"destination": "/gateway/external-apps"
},
{
"source": "/reference/maturity-scorecard",
"destination": "/maturity/scorecard"
},
{
"source": "/reference/maturity-taxonomy",
"destination": "/maturity/taxonomy"
},
{
"source": "/mcp",
"destination": "/cli/mcp"
@@ -1860,8 +1852,6 @@
{
"group": "Release and CI",
"pages": [
"maturity/scorecard",
"maturity/taxonomy",
"reference/RELEASING",
"reference/full-release-validation",
"reference/release-performance-sweep",

View File

@@ -110,18 +110,14 @@ systemctl --user daemon-reload
### Windows (Scheduled Task)
Default task name is `OpenClaw Gateway` (or `OpenClaw Gateway (<profile>)`).
The task script lives under your state dir as `gateway.cmd`; current installs may
also create a windowless `gateway.vbs` launcher that Task Scheduler runs instead
of opening `gateway.cmd` directly.
The task script lives under your state dir.
```powershell
schtasks /Delete /F /TN "OpenClaw Gateway"
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd" -ErrorAction SilentlyContinue
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.vbs" -ErrorAction SilentlyContinue
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd"
```
If you used a profile, delete the matching task name and the `gateway.cmd` /
`gateway.vbs` files under `~\.openclaw-<profile>`.
If you used a profile, delete the matching task name and `~\.openclaw-<profile>\gateway.cmd`.
## Normal install vs source checkout

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -124,11 +124,8 @@ openclaw gateway status --json
```
Native Windows CLI and Gateway flows are supported and continue to improve.
Managed startup uses Windows Scheduled Tasks when available. The task keeps the
readable `gateway.cmd` script in the OpenClaw state dir, but launches it through
a generated `gateway.vbs` WScript wrapper so the background Gateway does not open
a visible console window. If task creation is denied, OpenClaw falls back to a
per-user Startup-folder login item.
Managed startup uses Windows Scheduled Tasks when available and falls back to a
per-user Startup-folder login item if task creation is denied.
To install the Gateway service:

View File

@@ -259,10 +259,14 @@ under `describe("runSideQuestion")`.
fallback for whatever runtimes do not have a peer surface.
- PI session state is not migrated when an agent switches to `copilot`.
Selection is per attempt; existing PI sessions remain valid.
- `ask_user` uses the same OpenClaw prompt-and-reply path as the Codex
harness. When the Copilot SDK asks for user input, OpenClaw posts a
blocking prompt to the active channel/TUI and the next queued user
message resolves the SDK request.
- **Interactive `ask_user` is not yet wired.** The SDK's
`onUserInputRequest` handler is intentionally not registered, which
per the SDK contract hides the `ask_user` tool from the model
entirely. Agents running under this harness make best-judgment
decisions from the initial prompt rather than asking clarifying
questions mid-turn. A follow-up will port the codex pattern at
`extensions/codex/src/app-server/user-input-bridge.ts` to route SDK
`UserInputRequest`s through the OpenClaw channel/TUI prompt path.
## Permissions and ask_user
@@ -324,15 +328,11 @@ the tool bridge. The bridge also forwards the bounded tool-construction
controls it can enforce at the SDK boundary: `includeCoreTools`, the
runtime tool allowlist, and `toolConstructionPlan`.
The bridge also uses the shared harness tool-surface helper from
`openclaw/plugin-sdk/agent-harness-tool-runtime` for PI parity. When
tool-search is enabled, the SDK sees compact control tools plus a hidden
catalog executor instead of every OpenClaw tool schema. When code mode is
enabled, the helper builds the same code-mode control surface and catalog
lifecycle used by other agent harnesses. Local-model lean defaults,
runtime-compatible schema filtering, directory hydration, and catalog
cleanup all stay in the shared helper so Copilot and Codex-adjacent
harnesses do not drift.
The remaining PI tool-search/code-mode fields are intentionally **not**
forwarded at MVP and tracked as follow-ups: `toolSearchCatalogRef`,
`includeToolSearchControls`, and `toolSearchCatalogExecutor`. Those
controls drive PI's native tool-search UI and have no direct Copilot SDK
analog yet.
### Session-level GitHub token
@@ -349,10 +349,7 @@ When the resolved mode is `useLoggedInUser`, the session-level field
is omitted so the SDK keeps deriving identity from the logged-in
identity.
`ask_user` uses `SessionConfig.onUserInputRequest`. The bridge accepts
choice indexes or labels for fixed-choice requests, accepts free-form
answers when the SDK request allows them, and cancels a pending request
when the OpenClaw attempt is aborted.
`ask_user` is intentionally hidden — see Limitations above.
## Related

View File

@@ -46,8 +46,10 @@ The default model is `embeddinggemma-300m-qat-Q8_0.gguf`. You can also point
## Native Runtime
Use Node 24 for the smoothest native install path. Source checkouts using pnpm
may need to approve and rebuild the native dependency:
Use Node 24 for the smoothest native install path. Source checkouts leave the
native build unapproved by default so normal workspace installs do not compile
llama.cpp. When you actually use local GGUF embeddings from source, approve and
rebuild the provider runtime:
```bash
pnpm approve-builds

View File

@@ -196,23 +196,6 @@ finish. Both helpers accept the same `{ event, ctx }` payload as
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
attempt result.
### User input and tool surfaces
Native harnesses that expose a runtime-level user-input request should use the
user-input helpers from `openclaw/plugin-sdk/agent-harness-runtime` to format
the prompt, deliver it through OpenClaw's blocking reply path, and normalize
choice/free-form answers back into the runtime's native response shape. The
helper keeps channel/TUI presentation consistent while each harness keeps its
own protocol parsing and pending-request lifecycle.
Native harnesses that need PI-like compact tool routing should use
`createAgentHarnessToolSurfaceRuntime(...)` from
`openclaw/plugin-sdk/agent-harness-tool-runtime`. It owns
tool-search/code-mode control selection, local-model lean defaults,
runtime-compatible schema filtering, hidden catalog execution, directory
hydration, and catalog cleanup. Harnesses still own their SDK-specific tool
conversion and native execution callback.
### Native Codex harness mode
The bundled `codex` harness is the native Codex mode for embedded OpenClaw

View File

@@ -84,8 +84,8 @@ Choose the Token Plan auth choice that matches the regional base URL shown in Xi
| Model ref | Input | Context | Max output | Reasoning | Notes |
| --------------------------------- | ----------- | --------- | ---------- | --------- | ------------- |
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 131,072 | Yes | Default model |
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 131,072 | Yes | Multimodal |
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 32,000 | Yes | Default model |
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 32,000 | Yes | Multimodal |
<Tip>
Token Plan onboarding validates the key shape and warns when a `tp-...` key is entered into the pay-as-you-go path, or an `sk-...` key is entered into the Token Plan path.
@@ -222,7 +222,7 @@ Token Plan:
reasoning: true,
input: ["text"],
contextWindow: 1048576,
maxTokens: 131072,
maxTokens: 32000,
},
{
id: "mimo-v2.5",
@@ -230,7 +230,7 @@ Token Plan:
reasoning: true,
input: ["text", "image"],
contextWindow: 1048576,
maxTokens: 131072,
maxTokens: 32000,
},
],
},

View File

@@ -135,753 +135,3 @@ html.dark .nav-tabs-underline {
grid-template-columns: 1fr;
}
}
.maturity-hero {
display: grid;
gap: 14px;
margin: 10px 0 38px;
padding: 4px 0 26px 20px;
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 22%, transparent);
border-left: 3px solid rgb(var(--primary));
}
.maturity-hero-compact {
margin-bottom: 34px;
}
.maturity-hero h2 {
max-width: 46rem;
margin: 0;
font-size: clamp(26px, 3vw, 38px);
line-height: 1.08;
letter-spacing: -0.01em;
}
.maturity-hero-title {
max-width: 46rem;
margin: 0;
font-size: clamp(26px, 3vw, 38px);
font-weight: 750;
line-height: 1.08;
letter-spacing: -0.01em;
}
.maturity-hero > p:not(.maturity-kicker):not(.maturity-jump-links) {
max-width: 58rem;
margin: 0;
font-size: 16px;
line-height: 1.65;
opacity: 0.76;
}
.maturity-kicker {
margin: 0;
color: rgb(var(--primary));
font-size: 10px;
font-weight: 750;
letter-spacing: 0.1em;
line-height: 1.3;
text-transform: uppercase;
}
.maturity-jump-links {
margin: 0;
font-size: 13px;
line-height: 1.5;
}
.maturity-jump-links a {
text-decoration: none;
}
.maturity-jump-links a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.maturity-score-stable,
.maturity-band-stable {
color: #4ca574;
}
.maturity-score-beta,
.maturity-band-beta {
color: #849fd2;
}
.maturity-score-alpha,
.maturity-band-alpha {
color: #d39a4b;
}
.maturity-score-experimental,
.maturity-band-experimental {
color: #dc7669;
}
.maturity-score-clawesome,
.maturity-band-clawesome {
color: #46b59a;
}
.maturity-level-pill {
display: inline-flex;
align-items: center;
gap: 6px;
width: max-content;
max-width: 100%;
padding: 3px 8px;
border: 1px solid color-mix(in oklab, currentColor 32%, transparent);
border-radius: 999px;
background: color-mix(in oklab, currentColor 10%, transparent);
color: inherit;
font-size: 10px;
font-weight: 750;
line-height: 1.25;
white-space: nowrap;
}
.maturity-level-code {
font-size: 9px;
letter-spacing: 0.04em;
opacity: 0.72;
}
.maturity-level-experimental {
color: #dc7669;
}
.maturity-level-alpha {
color: #d39a4b;
}
.maturity-level-beta {
color: #849fd2;
}
.maturity-level-stable {
color: #4ca574;
}
.maturity-level-clawesome {
color: #46b59a;
}
.maturity-summary-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 14px 0 20px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
}
.maturity-summary-item {
display: grid;
gap: 10px;
min-width: 0;
padding: 18px 20px 18px 0;
}
.maturity-summary-item + .maturity-summary-item {
padding-left: 20px;
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
}
.maturity-summary-heading {
display: flex;
align-items: baseline;
gap: 9px;
}
.maturity-summary-value {
display: inline-block;
font-size: 30px;
font-weight: 750;
letter-spacing: -0.04em;
}
.maturity-summary-heading > span:not(.maturity-summary-value) {
font-size: 13px;
font-weight: 700;
}
.maturity-summary-bar {
height: 7px;
overflow: hidden;
background: color-mix(in oklab, currentColor 14%, transparent);
}
.maturity-summary-bar span {
display: block;
width: calc(var(--score) * 1%);
height: 100%;
background: currentColor;
}
.maturity-summary-meta {
display: flex;
flex-wrap: wrap;
gap: 4px 10px;
font-size: 11px;
line-height: 1.4;
}
.maturity-summary-meta span:first-child {
font-weight: 700;
}
.maturity-summary-meta span:last-child {
opacity: 0.62;
}
.maturity-band-list {
display: flex;
margin: 12px 0 30px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
}
.maturity-band {
display: grid;
flex: 1 1 0;
gap: 3px;
padding: 10px 12px 11px 0;
}
.maturity-band + .maturity-band {
padding-left: 12px;
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
}
.maturity-band-title {
font-size: 12px;
font-weight: 700;
}
.maturity-band-title + span {
color: inherit;
font-size: 11px;
opacity: 0.7;
}
.maturity-band > span:last-child {
color: inherit;
font-size: 11px;
opacity: 0.7;
}
.maturity-band span {
color: inherit;
font-size: 11px;
opacity: 0.7;
}
.maturity-band .maturity-level-pill {
font-size: 10px;
opacity: 1;
}
.maturity-band .maturity-level-pill span {
font-size: inherit;
opacity: inherit;
}
.maturity-score {
display: grid;
gap: 4px;
min-width: 0;
color: inherit;
font-size: 11px;
font-weight: 700;
}
.maturity-score-label {
display: flex;
justify-content: space-between;
gap: 6px;
line-height: 1.2;
}
.maturity-score-label > span:first-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.maturity-score-label > span:last-child {
flex: 0 0 auto;
}
.maturity-score-label .maturity-level-pill {
gap: 4px;
padding: 2px 6px;
font-size: 9px;
}
.maturity-score-label-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.maturity-summary-meta .maturity-level-pill {
opacity: 1;
}
.maturity-meter {
display: inline-block;
width: 100%;
height: 3px;
overflow: hidden;
background: color-mix(in oklab, currentColor 15%, transparent);
vertical-align: middle;
}
.maturity-meter > span {
display: block;
width: 0;
height: 100%;
background: currentColor;
}
.maturity-score-unscored,
.maturity-lts-none {
color: inherit;
opacity: 0.52;
}
.maturity-surface-table {
display: grid;
gap: 0;
margin: 8px 0 22px;
}
.maturity-surface-row {
display: grid;
grid-template-columns: minmax(190px, 1.55fr) repeat(3, minmax(110px, 1fr)) minmax(72px, 0.55fr);
gap: 12px;
align-items: center;
padding: 13px 0;
border-top: 1px solid color-mix(in oklab, currentColor 14%, transparent);
}
.maturity-surface-row-header {
padding: 0 0 9px;
border-top: 0;
color: inherit;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
opacity: 0.56;
text-transform: uppercase;
}
.maturity-surface-name {
display: grid;
gap: 3px;
min-width: 0;
border-bottom: 0 !important;
text-decoration: none;
}
.maturity-surface-name:hover .maturity-surface-title {
color: rgb(var(--primary));
}
.maturity-surface-title {
overflow-wrap: anywhere;
font-size: 13px;
font-weight: 700;
line-height: 1.25;
}
.maturity-surface-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
min-width: 0;
}
.maturity-surface-meta > span:not(.maturity-level-pill) {
font-size: 11px;
opacity: 0.62;
}
.maturity-surface-meta .maturity-level-pill {
opacity: 1;
}
.maturity-surface-metric {
min-width: 0;
}
.maturity-surface-metric-label {
display: none;
font-size: 10px;
opacity: 0.62;
}
.maturity-surface-support {
justify-self: start;
}
.maturity-lts {
display: inline-flex;
align-items: center;
gap: 5px;
color: inherit;
font-size: 11px;
font-weight: 700;
white-space: nowrap;
}
.maturity-lts::before {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
content: "";
}
.maturity-lts-partial {
color: #d39a4b;
}
.maturity-lts-full {
color: #4ca574;
}
.maturity-evidence-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
margin: 14px 0 24px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
}
.maturity-evidence-card {
display: grid;
gap: 4px;
min-width: 0;
padding: 14px 16px 14px 0;
}
.maturity-evidence-card + .maturity-evidence-card {
padding-left: 16px;
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
}
.maturity-evidence-title {
font-size: 13px;
font-weight: 700;
}
.maturity-evidence-card span {
font-size: 11px;
line-height: 1.4;
opacity: 0.68;
}
.maturity-readiness-summary {
margin: 0 0 12px;
font-size: 12px;
opacity: 0.65;
}
.maturity-readiness-list {
display: grid;
margin: 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
}
.maturity-readiness-row {
display: grid;
grid-template-columns: minmax(0, 1.7fr) minmax(120px, 0.8fr) minmax(110px, 0.7fr);
gap: 14px;
align-items: center;
padding: 11px 0;
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 10%, transparent);
font-size: 11px;
}
.maturity-readiness-row-header {
padding: 8px 0;
border-bottom-color: color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
opacity: 0.56;
text-transform: uppercase;
}
.maturity-readiness-area {
display: grid;
gap: 3px;
min-width: 0;
}
.maturity-readiness-title {
overflow-wrap: anywhere;
font-weight: 700;
}
.maturity-readiness-status {
font-size: 10px;
opacity: 0.62;
}
.maturity-readiness-status-ready {
color: #4ca574;
}
.maturity-readiness-status-partially-reviewed {
color: #d39a4b;
}
.maturity-readiness-status-needs-review {
color: #dc7669;
}
.maturity-category-list {
display: grid;
width: 100%;
margin-top: 4px;
overflow: hidden;
}
.maturity-category-row {
display: grid;
grid-template-columns: minmax(180px, 1.55fr) repeat(3, minmax(100px, 1fr)) minmax(140px, 1.2fr);
gap: 12px;
align-items: center;
padding: 12px 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
font-size: 11px;
}
.maturity-category-row-header {
padding: 8px 0;
border-top: 0;
color: inherit;
font-size: 10px;
font-weight: 750;
letter-spacing: 0.04em;
opacity: 0.56;
text-transform: uppercase;
}
.maturity-category-area {
display: grid;
gap: 3px;
min-width: 0;
}
.maturity-category-title {
overflow-wrap: anywhere;
font-weight: 700;
}
.maturity-category-area > span:last-child {
font-size: 10px;
opacity: 0.62;
}
.maturity-category-docs {
min-width: 0;
overflow-wrap: anywhere;
line-height: 1.4;
}
.maturity-category-docs a {
text-decoration: none;
}
.maturity-category-docs a:hover {
text-decoration: underline;
text-underline-offset: 3px;
}
.maturity-level-list {
display: grid;
margin: 12px 0 28px;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
}
.maturity-level-row {
display: grid;
grid-template-columns: minmax(130px, 0.32fr) minmax(0, 1fr);
gap: 4px 14px;
padding: 13px 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
}
.maturity-level-row:first-child {
border-top: 0;
}
.maturity-level-title {
grid-row: span 2;
font-size: 13px;
font-weight: 700;
}
.maturity-level-title .maturity-level-pill {
opacity: 1;
}
.maturity-level-row span,
.maturity-level-promotion {
font-size: 12px;
line-height: 1.45;
opacity: 0.68;
}
.maturity-surface-link {
display: grid;
gap: 3px;
margin: 0;
padding: 11px 0;
border-bottom: 1px solid color-mix(in oklab, currentColor 14%, transparent);
text-decoration: none;
}
.maturity-surface-link:hover {
color: rgb(var(--primary));
}
.maturity-surface-link .maturity-surface-title {
font-size: 13px;
font-weight: 700;
}
.maturity-surface-link > .maturity-surface-meta {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.maturity-surface-link > .maturity-surface-meta > span:not(.maturity-level-pill) {
font-size: 11px;
opacity: 0.68;
}
.maturity-surface-link > .maturity-surface-meta .maturity-level-pill {
opacity: 1;
}
.maturity-surface-rollup {
display: flex;
flex-wrap: wrap;
gap: 5px 14px;
margin: 0 0 14px;
padding: 9px 0;
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
}
.maturity-surface-rollup > span {
color: inherit;
font-size: 11px;
font-weight: 700;
}
#content table .maturity-score,
#content table .maturity-lts {
white-space: nowrap;
}
@media (max-width: 960px) {
.maturity-summary-grid,
.maturity-evidence-grid {
grid-template-columns: 1fr;
}
.maturity-summary-item,
.maturity-summary-item + .maturity-summary-item,
.maturity-evidence-card,
.maturity-evidence-card + .maturity-evidence-card {
padding: 14px 0;
border-left: 0;
}
.maturity-summary-item + .maturity-summary-item,
.maturity-evidence-card + .maturity-evidence-card {
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
}
.maturity-surface-row {
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(98px, 1fr)) minmax(70px, 0.5fr);
gap: 8px;
}
.maturity-category-row {
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(86px, 1fr)) minmax(110px, 1fr);
gap: 8px;
}
}
@media (max-width: 640px) {
.maturity-hero {
padding-left: 14px;
}
.maturity-surface-row-header {
display: none;
}
.maturity-surface-row {
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
gap: 9px 12px;
}
.maturity-surface-metric {
display: grid;
grid-template-columns: auto 1fr;
align-items: center;
gap: 6px;
}
.maturity-surface-metric-label {
display: block;
}
.maturity-surface-support {
justify-self: end;
}
.maturity-readiness-row,
.maturity-category-row {
grid-template-columns: 1fr;
gap: 5px;
}
.maturity-readiness-row-header,
.maturity-category-row-header {
display: none;
}
.maturity-category-docs {
padding-top: 3px;
}
.maturity-band-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.maturity-band + .maturity-band {
padding-left: 0;
border-left: 0;
}
.maturity-level-row {
grid-template-columns: 1fr;
}
.maturity-level-title {
grid-row: auto;
}
}

View File

@@ -88,9 +88,8 @@ still returns one synthesized answer with citations rather than an N-result
list.
`freshness` accepts `day`, `week`, `month`, `year`, and the shared shortcuts
`pd`, `pw`, `pm`, and `py`. `day`/`pd` adds a recency instruction to the Gemini
query instead of a hard 24-hour range. `week`, `month`, `year`, and explicit
`date_after`/`date_before` ranges set Gemini Google Search grounding's
`pd`, `pw`, `pm`, and `py`. OpenClaw converts these values, or an explicit
`date_after`/`date_before` range, into Gemini Google Search grounding's
`timeRangeFilter`. `country`, `language`, and `domain_filter` are not supported.
## Model selection

View File

@@ -27,13 +27,7 @@ plugin, ClawHub, extra-root, managed, personal-agent, or system skills.
active skills.
- **Workspace scoped:** creates target the workspace `skills/` root. Updates
are allowed only for writable workspace skills.
- **Single target:** create always proposes one new sibling skill, and update
targets one existing skill. Split multi-skill changes into separate update
proposals.
- **No clobber:** create fails if the target skill already exists.
- **Wrong-target guard:** create rejects proposal content that references
existing workspace skill paths such as `skills/trip-planning/SKILL.md`;
those changes belong in update proposals.
- **Hash bound:** update proposals bind to the current target hash and become
stale if the live skill changes before apply.
- **Scanner gated:** apply reruns scanning before writing.
@@ -94,20 +88,12 @@ openclaw skills workshop propose-create \
--proposal ./PROPOSAL.md
```
`propose-create` always creates a new sibling under `skills/<name>/`. If the
draft references an existing workspace skill path such as
`skills/trip-planning/SKILL.md`, Skill Workshop rejects it so the update does
not silently land in an unused sibling skill.
Create an update proposal for an existing workspace skill:
```bash
openclaw skills workshop propose-update trip-planning --proposal ./PROPOSAL.md
```
For changes that touch multiple existing skills, create one update proposal per
target skill.
List and inspect:
```bash
@@ -181,10 +167,6 @@ The model uses `skill_workshop`:
action: create | update | revise | list | inspect | apply | reject | quarantine
```
`action=create` is only for a brand-new workspace skill. `action=update` targets
one existing skill through `skill_name`. Agents should split multi-skill patches
into separate `action=update` calls before applying them.
Agents must use `skill_workshop` for generated skill work. They must not create
or change proposal files through `write`, `edit`, `exec`, shell commands, or
direct filesystem operations.

View File

@@ -389,8 +389,8 @@ show the `x_search` prompt.
freshness ranges require both start and end dates.
Gemini, Grok, and Kimi return one synthesized answer with citations. They
accept `count` for shared-tool compatibility, but it does not change the
grounded answer shape. Gemini treats `day` freshness as a recency hint; wider
freshness values and explicit dates set Google Search grounding time ranges.
grounded answer shape. Gemini supports `freshness`, `date_after`, and
`date_before` by converting them to Google Search grounding time ranges.
Perplexity behaves the same way when you use the Sonar/OpenRouter
compatibility path (`plugins.entries.perplexity.config.webSearch.baseUrl` /
`model` or `OPENROUTER_API_KEY`).

View File

@@ -701,7 +701,6 @@
"version": "0.15.0",
"resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.15.0.tgz",
"integrity": "sha512-eAv7sGBeiYrYkOulF729nrM51szS7WIhBtugRj5wWq6csRKZUhAZfoUZlF8xUWdHPtOIzd/eT6MNG6gMHu6z0w==",
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"bin": {
"codex-acp": "bin/codex-acp.js"
@@ -722,7 +721,6 @@
"cpu": [
"arm64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -739,7 +737,6 @@
"cpu": [
"x64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -756,7 +753,6 @@
"cpu": [
"arm64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -773,7 +769,6 @@
"cpu": [
"x64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -790,7 +785,6 @@
"cpu": [
"arm64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [
@@ -807,7 +801,6 @@
"cpu": [
"x64"
],
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
"license": "Apache-2.0",
"optional": true,
"os": [

View File

@@ -15,12 +15,8 @@ import { resolvePnpmRunner } from "./pnpm-runner.mjs";
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const rootDir = path.resolve(pluginDir, "../..");
const require = createRequire(import.meta.url);
const hashFile =
process.env.OPENCLAW_A2UI_BUNDLE_HASH_FILE ??
path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile =
process.env.OPENCLAW_A2UI_BUNDLE_OUT ??
path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>

View File

@@ -11,9 +11,7 @@ const repoRoot = path.resolve(here, "../../../../..");
const require = createRequire(import.meta.url);
const uiRoot = path.resolve(repoRoot, "ui");
const fromHere = (p) => path.resolve(here, p);
const outputFile = process.env.OPENCLAW_A2UI_BUNDLE_OUT
? path.resolve(process.env.OPENCLAW_A2UI_BUNDLE_OUT)
: path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const outputFile = path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
const a2uiLitIndex = require.resolve("@a2ui/lit");
const a2uiLitUi = require.resolve("@a2ui/lit/ui");

View File

@@ -3,12 +3,7 @@
* turns replies into app-server answer payloads.
*/
import {
buildAgentHarnessUserInputAnswers,
deliverAgentHarnessUserInputPrompt,
embeddedAgentLog,
emptyAgentHarnessUserInputAnswers,
type AgentHarnessUserInputOption,
type AgentHarnessUserInputQuestion,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { formatCodexDisplayText } from "../command-formatters.js";
@@ -24,11 +19,25 @@ type PendingUserInput = {
threadId: string;
turnId: string;
itemId: string;
questions: AgentHarnessUserInputQuestion[];
questions: UserInputQuestion[];
resolve: (value: JsonValue) => void;
cleanup: () => void;
};
type UserInputQuestion = {
id: string;
header: string;
question: string;
isOther: boolean;
isSecret: boolean;
options: UserInputOption[] | null;
};
type UserInputOption = {
label: string;
description: string;
};
type CodexUserInputBridge = {
handleRequest: (request: {
id: number | string;
@@ -133,7 +142,7 @@ function readUserInputParams(value: JsonValue | undefined):
threadId: string;
turnId: string;
itemId: string;
questions: AgentHarnessUserInputQuestion[];
questions: UserInputQuestion[];
}
| undefined {
if (!isJsonObject(value)) {
@@ -148,11 +157,11 @@ function readUserInputParams(value: JsonValue | undefined):
}
const questions = questionsRaw
.map(readQuestion)
.filter((question): question is AgentHarnessUserInputQuestion => Boolean(question));
.filter((question): question is UserInputQuestion => Boolean(question));
return { threadId, turnId, itemId, questions };
}
function readQuestion(value: JsonValue): AgentHarnessUserInputQuestion | undefined {
function readQuestion(value: JsonValue): UserInputQuestion | undefined {
if (!isJsonObject(value)) {
return undefined;
}
@@ -172,17 +181,17 @@ function readQuestion(value: JsonValue): AgentHarnessUserInputQuestion | undefin
};
}
function readOptions(value: JsonValue | undefined): AgentHarnessUserInputOption[] | null {
function readOptions(value: JsonValue | undefined): UserInputOption[] | null {
if (!Array.isArray(value)) {
return null;
}
const options = value
.map(readOption)
.filter((option): option is AgentHarnessUserInputOption => Boolean(option));
.filter((option): option is UserInputOption => Boolean(option));
return options.length > 0 ? options : null;
}
function readOption(value: JsonValue): AgentHarnessUserInputOption | undefined {
function readOption(value: JsonValue): UserInputOption | undefined {
if (!isJsonObject(value)) {
return undefined;
}
@@ -193,25 +202,116 @@ function readOption(value: JsonValue): AgentHarnessUserInputOption | undefined {
async function deliverUserInputPrompt(
params: EmbeddedRunAttemptParams,
questions: AgentHarnessUserInputQuestion[],
questions: UserInputQuestion[],
): Promise<void> {
await deliverAgentHarnessUserInputPrompt(params, questions, {
formatText: formatCodexDisplayText,
intro: "Codex needs input:",
});
const text = formatUserInputPrompt(questions);
if (params.onBlockReply) {
await params.onBlockReply({ text });
return;
}
await params.onPartialReply?.({ text });
}
function buildUserInputResponse(
questions: AgentHarnessUserInputQuestion[],
inputText: string,
): JsonObject {
function formatUserInputPrompt(questions: UserInputQuestion[]): string {
const lines = ["Codex needs input:"];
questions.forEach((question, index) => {
if (questions.length > 1) {
lines.push(
"",
`${index + 1}. ${formatCodexDisplayText(question.header)}`,
formatCodexDisplayText(question.question),
);
} else {
lines.push(
"",
formatCodexDisplayText(question.header),
formatCodexDisplayText(question.question),
);
}
if (question.isSecret) {
lines.push("This channel may show your reply to other participants.");
}
question.options?.forEach((option, optionIndex) => {
lines.push(
`${optionIndex + 1}. ${formatCodexDisplayText(option.label)}${
option.description ? ` - ${formatCodexDisplayText(option.description)}` : ""
}`,
);
});
if (question.isOther) {
lines.push("Other: reply with your own answer.");
}
});
return lines.join("\n");
}
function buildUserInputResponse(questions: UserInputQuestion[], inputText: string): JsonObject {
// Multi-question replies may use "header: answer" or numbered lines. Keep the
// parser permissive so chat-channel replies remain ergonomic.
return buildAgentHarnessUserInputAnswers(questions, inputText) as unknown as JsonObject;
const answers: JsonObject = {};
if (questions.length === 1) {
const question = questions[0];
if (question) {
const answer = normalizeAnswer(inputText, question);
answers[question.id] = { answers: answer ? [answer] : [] };
}
return { answers };
}
const keyed = parseKeyedAnswers(inputText);
const fallbackLines = inputText
.split(/\r?\n/)
.map((line) => line.trim())
.filter(Boolean);
questions.forEach((question, index) => {
const key =
keyed.get(question.id.toLowerCase()) ??
keyed.get(question.header.toLowerCase()) ??
keyed.get(question.question.toLowerCase()) ??
keyed.get(String(index + 1));
const answer = key ?? fallbackLines[index] ?? "";
const normalized = answer ? normalizeAnswer(answer, question) : undefined;
answers[question.id] = { answers: normalized ? [normalized] : [] };
});
return { answers };
}
function normalizeAnswer(answer: string, question: UserInputQuestion): string | undefined {
const trimmed = answer.trim();
const options = question.options ?? [];
const optionIndex = /^\d+$/.test(trimmed) ? Number(trimmed) - 1 : -1;
const indexed = optionIndex >= 0 ? options[optionIndex] : undefined;
if (indexed) {
return indexed.label;
}
const exact = options.find((option) => option.label.toLowerCase() === trimmed.toLowerCase());
if (exact) {
return exact.label;
}
if (options.length > 0 && !question.isOther) {
return undefined;
}
return trimmed || undefined;
}
function parseKeyedAnswers(inputText: string): Map<string, string> {
const answers = new Map<string, string>();
for (const line of inputText.split(/\r?\n/)) {
const match = line.match(/^\s*([^:=-]+?)\s*[:=-]\s*(.+?)\s*$/);
if (!match) {
continue;
}
const key = match[1]?.trim().toLowerCase();
const value = match[2]?.trim();
if (key && value) {
answers.set(key, value);
}
}
return answers;
}
function emptyUserInputResponse(): JsonObject {
return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject;
return { answers: {} };
}
function readString(record: JsonObject, key: string): string | undefined {

View File

@@ -1609,12 +1609,6 @@ describe("createCopilotAgentHarness", () => {
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
summaryContent: "compacted summary",
contextWindow: {
tokenLimit: 1000,
currentTokens: 777,
messagesLength: 12,
},
}));
const disconnect = vi.fn(async () => {
throw new Error("disconnect failed");
@@ -1655,7 +1649,6 @@ describe("createCopilotAgentHarness", () => {
model: "gpt-4.1",
sessionKey: "agent:main:main",
sessionId: "oc-sess-compact-1",
currentTokenCount: 900,
workspaceDir: "/this\u0000is/illegal",
customInstructions: "Keep decisions.",
});
@@ -1691,25 +1684,6 @@ describe("createCopilotAgentHarness", () => {
ok: true,
compacted: true,
reason: "copilot-sdk-history-compacted",
result: {
summary: "compacted summary",
firstKeptEntryId: "",
tokensBefore: 900,
tokensAfter: 777,
details: {
success: true,
tokensRemoved: 123,
messagesRemoved: 4,
summaryContent: "compacted summary",
contextWindow: {
tokenLimit: 1000,
currentTokens: 777,
messagesLength: 12,
},
},
sessionId: "oc-sess-compact-1",
sessionFile: "/session.json",
},
});
});

View File

@@ -62,14 +62,6 @@ interface CopilotHistoryCompactResult {
tokensRemoved: number;
messagesRemoved: number;
summaryContent?: string;
contextWindow?: {
tokenLimit: number;
currentTokens: number;
messagesLength: number;
systemTokens?: number;
conversationTokens?: number;
toolDefinitionsTokens?: number;
};
}
interface CopilotHistoryCompactSession {
@@ -880,21 +872,6 @@ export function createCopilotAgentHarness(
ok: true,
compacted,
reason: compacted ? "copilot-sdk-history-compacted" : "already under target",
...(compacted
? {
result: {
summary: compactResult.summaryContent ?? "",
firstKeptEntryId: "",
tokensBefore:
params.currentTokenCount ??
(compactResult.contextWindow?.currentTokens ?? 0) + compactResult.tokensRemoved,
tokensAfter: compactResult.contextWindow?.currentTokens,
details: compactResult,
sessionId: params.sessionId,
sessionFile: params.sessionFile,
},
}
: {}),
};
},

View File

@@ -3,11 +3,9 @@ import fsp from "node:fs/promises";
import { tmpdir } from "node:os";
import path from "node:path";
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
import {
abortAgentHarnessRun,
queueAgentHarnessMessage,
type AgentHarnessAttemptParams,
type AgentHarnessAttemptResult,
import type {
AgentHarnessAttemptParams,
AgentHarnessAttemptResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
import {
@@ -1173,36 +1171,6 @@ describe("runCopilotAttempt", () => {
expect(result.externalAbort).toBe(true);
});
it("active-run abort path marks the attempt as externally aborted", async () => {
const sendDeferred = createDeferred<SessionEventShape | undefined>();
const sessionCreated = createDeferred<FakeSession>();
const sdk = makeFakeSdk({
onCreateSession: (session) => {
session.sendAndWait.mockReturnValue(sendDeferred.promise);
session.abort.mockImplementationOnce(async () => {
sendDeferred.resolve(undefined);
});
sessionCreated.resolve(session);
},
});
const pool = makeFakePool(sdk);
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
const runPromise = runCopilotAttempt(makeParams(), {
createToolBridge,
pool,
});
const session = await sessionCreated.promise;
await vi.waitFor(() => expect(session.sendAndWait).toHaveBeenCalledTimes(1));
expect(abortAgentHarnessRun("session-1")).toBe(true);
const result = await runPromise;
expect(session.abort).toHaveBeenCalledTimes(1);
expect(result.aborted).toBe(true);
expect(result.externalAbort).toBe(true);
});
it("abort path (signal already aborted)", async () => {
const controller = new AbortController();
controller.abort();
@@ -1479,42 +1447,18 @@ describe("runCopilotAttempt", () => {
expect(result.feedback).toContain("no permission policy installed");
});
it("registers ask_user and resolves it from the active OpenClaw queue", async () => {
const onBlockReply = vi.fn();
const sdk = makeFakeSdk({
onCreateSession: (session, cfg) => {
session.sendAndWait.mockImplementationOnce(async () => {
const handler = cfg.onUserInputRequest;
if (typeof handler !== "function") {
throw new Error("expected onUserInputRequest handler");
}
const response = await handler(
{
question: "Pick a mode",
choices: ["Fast", "Deep"],
allowFreeform: false,
},
{ sessionId: session.sessionId },
);
return makeAssistantMessageEvent(`selected ${response.answer}`);
});
},
});
it("does not register onUserInputRequest (ask_user hidden from the model in MVP)", async () => {
const sdk = makeFakeSdk();
const pool = makeFakePool(sdk);
const attempt = runCopilotAttempt(makeParams({ onBlockReply }), { pool });
await vi.waitFor(() => expect(onBlockReply).toHaveBeenCalledTimes(1));
expect(queueAgentHarnessMessage("session-1", "2")).toBe(true);
const result = await attempt;
await runCopilotAttempt(makeParams(), { pool });
const cfg = sdk.createSession.mock.calls[0]?.[0];
expect(typeof cfg.onUserInputRequest).toBe("function");
expect(onBlockReply.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({ text: expect.stringContaining("Pick a mode") }),
);
expect(result.assistantTexts).toEqual(["selected Deep"]);
expect(queueAgentHarnessMessage("session-1", "late")).toBe(false);
// Per the SDK contract (types.d.ts: `When provided, enables the
// ask_user tool allowing the agent to ask questions`), omitting the
// handler hides ask_user from the model entirely. The MVP keeps it
// hidden until a real channel/TUI prompt bridge exists.
expect("onUserInputRequest" in cfg).toBe(false);
});
it("enableSessionTelemetry is omitted from createSession when undefined (SDK default)", async () => {
@@ -1910,7 +1854,6 @@ describe("runCopilotAttempt", () => {
it("retains a timed-out session until later compaction reaches session.idle", async () => {
const afterCompaction = vi.fn();
const onDeferredCompaction = vi.fn();
const cleanupToolBridge = vi.fn();
initializeGlobalHookRunner(
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
);
@@ -1923,14 +1866,8 @@ describe("runCopilotAttempt", () => {
);
},
});
const createToolBridge = vi.fn(async () => ({
cleanup: cleanupToolBridge,
sdkTools: [],
sourceTools: [],
}));
const result = await runCopilotAttempt(makeParams(), {
createToolBridge,
onDeferredCompaction,
pool: makeFakePool(sdk),
});
@@ -1940,7 +1877,6 @@ describe("runCopilotAttempt", () => {
expect(onDeferredCompaction).toHaveBeenCalledWith(
expect.objectContaining({ sdkSessionId: "sess-1" }),
);
expect(cleanupToolBridge).not.toHaveBeenCalled();
expect(activeSession?.disconnect).not.toHaveBeenCalled();
activeSession?.emit("session.compaction_start", {});
@@ -1954,7 +1890,6 @@ describe("runCopilotAttempt", () => {
await vi.waitFor(() => {
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
});
expect(cleanupToolBridge).toHaveBeenCalledTimes(1);
});
it("does not mark a timeout after SDK compaction has completed as active compaction", async () => {
@@ -3131,8 +3066,7 @@ describe("runCopilotAttempt", () => {
// permission policy and pollute the catalog under the default reject
// policy. `createSessionConfig` derives `availableTools` from the
// post-filter `sdkTools` so create- and resume-session always carry
// exactly the names of the tools the bridge actually exposed plus the
// built-in `ask_user` tool owned by the registered user-input handler.
// exactly the names of the tools the bridge actually exposed.
describe("availableTools surface restriction (PR #86155 [P1] round-8)", () => {
function makeFakeSdkTool(name: string): SdkTool {
return {
@@ -3156,11 +3090,7 @@ describe("runCopilotAttempt", () => {
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([
"read",
"edit",
"builtin:ask_user",
]);
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["read", "edit"]);
});
it("forwards `[]` to the SDK when the bridge returns no tools (disable / raw / fully filtered)", async () => {
@@ -3170,13 +3100,12 @@ describe("runCopilotAttempt", () => {
// (`modelRun: true` or `promptMode: "none"`), an empty
// `toolsAllow: []`, and an unsupported provider to `sdkTools: []`.
// Whatever the upstream reason, `availableTools` must be the same
// ask_user-only list so the SDK cannot fall back to its native
// catalog while the registered user-input handler remains usable.
// empty list so the SDK cannot fall back to its native catalog.
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["builtin:ask_user"]);
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([]);
});
it("forwards the full bridged set when the run is unrestricted (no toolsAllow)", async () => {
@@ -3202,7 +3131,6 @@ describe("runCopilotAttempt", () => {
"edit",
"exec",
"message",
"builtin:ask_user",
]);
});
@@ -3228,7 +3156,7 @@ describe("runCopilotAttempt", () => {
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
expect(resumeCfg?.availableTools).toEqual(["read", "builtin:ask_user"]);
expect(resumeCfg?.availableTools).toEqual(["read"]);
});
it("forwards `[]` to resumeSession when the bridge returns no tools", async () => {
@@ -3247,7 +3175,7 @@ describe("runCopilotAttempt", () => {
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
expect(resumeCfg?.availableTools).toEqual(["builtin:ask_user"]);
expect(resumeCfg?.availableTools).toEqual([]);
});
});

View File

@@ -24,8 +24,6 @@ import {
runAgentEndSideEffects,
runAgentHarnessLlmInputHook,
runAgentHarnessLlmOutputHook,
clearActiveEmbeddedRun,
setActiveEmbeddedRun,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { resolveCopilotAuth } from "./auth-bridge.js";
import {
@@ -57,12 +55,10 @@ import {
} from "./replay-shim.js";
import type { ClientCreateOptions, CopilotClientPool, PoolKey, PooledClient } from "./runtime.js";
import { createCopilotToolBridge } from "./tool-bridge.js";
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
import { resolveCopilotWorkspaceBootstrapContext } from "./workspace-bootstrap.js";
const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
const BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS = 5_000;
const COPILOT_ASK_USER_AVAILABLE_TOOLS = ["builtin:ask_user"] as const;
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
@@ -77,7 +73,6 @@ export type CopilotSessionConfig = Pick<
| "infiniteSessions"
| "model"
| "onPermissionRequest"
| "onUserInputRequest"
| "reasoningEffort"
| "systemMessage"
| "tools"
@@ -227,7 +222,6 @@ function deferBackgroundCompactionCleanup(params: {
bridge: ReturnType<typeof attachEventBridge>;
handle: PooledClient;
pool: CopilotClientPool;
cleanupToolBridge?: () => void;
sdkSessionId?: string;
session: SessionLike;
timeoutMs: number;
@@ -256,7 +250,6 @@ function deferBackgroundCompactionCleanup(params: {
} catch {
// The attempt has already returned its timeout result.
}
params.cleanupToolBridge?.();
if (outcome !== "completed" && params.sdkSessionId) {
try {
await params.handle.client.deleteSession(params.sdkSessionId);
@@ -410,9 +403,6 @@ export async function runCopilotAttempt(
let handle: PooledClient | undefined;
let session: SessionLike | undefined;
let bridge: ReturnType<typeof attachEventBridge> | undefined;
let activeRunHandleRef: Parameters<typeof clearActiveEmbeddedRun>[1] | undefined;
let userInputBridgeRef: ReturnType<typeof createCopilotUserInputBridge> | undefined;
let cleanupToolBridge: (() => void) | undefined;
let releaseError: Error | undefined;
let downgradedFromResume = false;
let resumeFailureRecovered = false;
@@ -425,24 +415,16 @@ export async function runCopilotAttempt(
// `src/agents/pi-embedded-runner/run/types.ts:139`.
let yieldDetected = false;
const markExternalAbort = () => {
const onAbort = () => {
abortRequested = true;
externalAbort = true;
aborted = true;
};
const abortActiveSession = () => {
markExternalAbort();
if (settled || !sentTurnStarted || !session) {
return;
}
void session.abort().catch(() => undefined);
};
const onAbort = () => {
abortActiveSession();
};
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
// Sandbox parity with PI (`src/agents/pi-embedded-runner/run/attempt.ts:1232-1244`):
@@ -593,7 +575,6 @@ export async function runCopilotAttempt(
startedAt,
}),
});
cleanupToolBridge = toolBridge.cleanup;
sdkTools = toolBridge.sdkTools;
} catch (error: unknown) {
const result = createResult(input, {
@@ -674,11 +655,6 @@ export async function runCopilotAttempt(
});
};
const hasNativePromptHook = Boolean(attemptInput.hooksConfig?.onUserPromptSubmitted);
const userInputBridge = createCopilotUserInputBridge({
paramsForRun: attemptInput,
signal: params.abortSignal,
});
userInputBridgeRef = userInputBridge;
const sessionConfig = createSessionConfig(
attemptInput,
modelRef.id,
@@ -687,7 +663,6 @@ export async function runCopilotAttempt(
promptBuild.developerInstructions || undefined,
effectiveWorkspaceDir,
effectiveCwd,
userInputBridge.onUserInputRequest,
hasNativePromptHook
? {
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
@@ -773,29 +748,6 @@ export async function runCopilotAttempt(
isAborted: () => aborted,
});
const activeRunHandle = {
kind: "embedded" as const,
queueMessage: async (text: string) => {
if (userInputBridge.handleQueuedMessage(text)) {
return;
}
throw new Error("Copilot runtime is not waiting for user input.");
},
isStreaming: () => !settled && !aborted,
isCompacting: () => bridge?.isCompacting() ?? false,
sourceReplyDeliveryMode: input.sourceReplyDeliveryMode,
cancel: () => {
userInputBridge.cancelPending();
abortActiveSession();
},
abort: () => {
userInputBridge.cancelPending();
abortActiveSession();
},
};
setActiveEmbeddedRun(input.sessionId, activeRunHandle, input.sessionKey, input.sessionFile);
activeRunHandleRef = activeRunHandle;
const messageOptions = await createMessageOptions(attemptInput, {
effectiveCwd,
effectiveWorkspaceDir,
@@ -854,15 +806,6 @@ export async function runCopilotAttempt(
}
} finally {
settled = true;
userInputBridgeRef?.cancelPending();
if (activeRunHandleRef) {
clearActiveEmbeddedRun(
input.sessionId,
activeRunHandleRef,
input.sessionKey,
input.sessionFile,
);
}
const retainSessionForDeferredCleanup =
bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false);
if (retainSessionForDeferredCleanup && bridge && session && handle) {
@@ -877,7 +820,6 @@ export async function runCopilotAttempt(
abortSignal: cleanupAbort.signal,
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
bridge,
cleanupToolBridge,
handle,
pool: deps.pool,
sdkSessionId,
@@ -906,7 +848,6 @@ export async function runCopilotAttempt(
// defines as no background agents in flight. Timeouts retain the bridge
// until that event so compaction that starts after the timer still completes.
await bridge?.awaitCompactionChain();
cleanupToolBridge?.();
bridge?.detach();
params.abortSignal?.removeEventListener("abort", onAbort);
@@ -1177,7 +1118,6 @@ function createSessionConfig(
systemMessageContent: string | undefined,
effectiveWorkspaceDir: string | undefined,
effectiveCwd: string | undefined,
onUserInputRequest: NonNullable<SessionConfig["onUserInputRequest"]>,
hooksBridgeOptions?: Parameters<typeof createHooksBridge>[1],
): CopilotSessionConfig {
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
@@ -1205,9 +1145,10 @@ function createSessionConfig(
// tool wrapper, and the SDK gate is a safety net for kinds we
// don't surface. See permission-bridge.ts and docs/plugins/copilot.md.
onPermissionRequest: createPermissionBridge(permissionPolicy),
// Registers the SDK ask_user bridge. The bridge itself owns pending
// reply routing so generic mid-run steering still fails closed.
onUserInputRequest,
// `onUserInputRequest` is intentionally NOT registered: per the SDK
// contract, omitting the handler hides the `ask_user` tool from the
// model entirely. Interactive ask_user will need a real channel/TUI
// prompt bridge before this runtime can expose the handler.
// Preserve the shipped native SDK hook contract. These callbacks expose
// Copilot-specific events and decisions that generic lifecycle hooks do
// not model.
@@ -1225,9 +1166,8 @@ function createSessionConfig(
...(infiniteSessions ? { infiniteSessions } : {}),
reasoningEffort: params.reasoningEffort,
tools: sdkTools,
// Restrict the SDK's tool catalog to the bridged tool names returned
// by `createCopilotToolBridge` plus the built-in `ask_user` tool owned
// by `onUserInputRequest`. Without this, the SDK
// Restrict the SDK's tool catalog to exactly the bridged tool names
// returned by `createCopilotToolBridge`. Without this, the SDK
// would still expose its native read/write/shell/url/mcp/memory/
// hook tools to the model alongside our overrides, which would
// bypass OpenClaw's wrapped-tool enforcement under any permissive
@@ -1242,7 +1182,7 @@ function createSessionConfig(
// `@github/copilot-sdk/dist/types.d.ts:1198` (it picks
// `availableTools`, so the spread into `resumeSession` covers
// the resume path too).
availableTools: buildCopilotAvailableTools(sdkTools),
availableTools: sdkTools.map((tool) => tool.name),
workingDirectory:
effectiveCwd ?? effectiveWorkspaceDir ?? readResolvedAttemptPath(params.workspaceDir),
// When a task runs from a sub-cwd, keep SDK-native project docs
@@ -1288,10 +1228,6 @@ function createSessionConfig(
};
}
function buildCopilotAvailableTools(sdkTools: SdkTool[]): string[] {
return [...new Set([...sdkTools.map((tool) => tool.name), ...COPILOT_ASK_USER_AVAILABLE_TOOLS])];
}
async function createMessageOptions(
params: AttemptParamsLike,
context: {

View File

@@ -156,184 +156,11 @@ describe("createCopilotToolBridge", () => {
sessionId: "session-1",
});
expect(result.sourceTools).toEqual(sourceTools);
expect(result.sourceTools).toBe(sourceTools);
expect(result.sdkTools).toHaveLength(2);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a", "tool-b"]);
});
it("compacts the Copilot tool surface behind tool_search controls when enabled", async () => {
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
const includeToolSearchControls = Boolean(
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
);
return includeToolSearchControls
? [
makeTool({ name: "tool_search_code" }),
makeTool({ name: "fake_hidden" }),
makeTool({ name: "read" }),
]
: [makeTool({ name: "fake_hidden" }), makeTool({ name: "read" })];
});
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { toolSearch: true } },
runId: "run-tool-search",
sessionKey: "agent:main:main",
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
expect.objectContaining({
includeToolSearchControls: true,
toolSearchCatalogRef: expect.any(Object),
toolSearchCatalogExecutor: expect.any(Function),
}),
);
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
});
it("keeps tool_search controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
const includeToolSearchControls = Boolean(
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
);
return includeToolSearchControls
? [makeTool({ name: "tool_search_code" }), makeTool({ name: "read" })]
: [makeTool({ name: "read" })];
});
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { toolSearch: true } },
runId: "run-tool-search",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
});
it("filters the hidden tool_search catalog before compacting narrowed tools", async () => {
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
return [
makeTool({ name: "tool_search_code" }),
makeTool({ name: "read" }),
makeTool({ name: "edit" }),
makeTool({ name: "write" }),
];
});
await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { toolSearch: true } },
runId: "run-tool-search",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
});
it("compacts the Copilot tool surface behind code-mode exec/wait when enabled", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
makeTool({ name: "read" }),
]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionKey: "agent:main:main",
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
expect.objectContaining({
includeToolSearchControls: false,
toolSearchCatalogRef: expect.any(Object),
toolSearchCatalogExecutor: expect.any(Function),
}),
);
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
const createOpenClawCodingTools = vi.fn(async () => [
makeTool({ name: "fake_hidden" }),
makeTool({ name: "read" }),
]);
const result = await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
});
it("filters the hidden code-mode catalog before compacting narrowed tools", async () => {
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
return [makeTool({ name: "read" }), makeTool({ name: "edit" }), makeTool({ name: "write" })];
});
await createCopilotToolBridge({
agentId: "agent-1",
attemptParams: {
config: { tools: { codeMode: true } },
runId: "run-code-mode",
sessionKey: "agent:main:main",
toolsAllow: ["read"],
} as never,
createOpenClawCodingTools,
modelId: "gpt-4o",
modelProvider: "github-copilot",
sessionId: "session-1",
});
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
});
it("throws when createOpenClawCodingTools returns a non-array", async () => {
await expect(
createCopilotToolBridge({

View File

@@ -17,15 +17,10 @@ import {
resolveModelAuthMode,
sanitizeToolResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
type CreateOpenClawCodingTools =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type OpenClawCodingToolsOptions = NonNullable<Parameters<CreateOpenClawCodingTools>[0]>;
type AgentHarnessToolSurfaceRuntime = ReturnType<typeof createAgentHarnessToolSurfaceRuntime>;
type CatalogExecuteParams = Parameters<
NonNullable<AgentHarnessToolSurfaceRuntime["toolSearchCatalogExecutor"]>
>[0];
type AgentToolResultLike = {
content?: unknown;
@@ -135,7 +130,6 @@ export interface CopilotToolBridgeInput {
}
export interface CopilotToolBridge {
cleanup?: () => void;
sdkTools: SdkTool[];
sourceTools: AnyAgentTool[];
}
@@ -184,31 +178,7 @@ export async function createCopilotToolBridge(
input.createOpenClawCodingTools ??
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
const toolSurfaceRuntime = createAgentHarnessToolSurfaceRuntime({
abortSignal: input.abortSignal,
agentId: input.agentId,
config: attemptParams.config,
disableTools: attemptParams.disableTools,
executeTool: (toolParams) => executeCatalogTool(input, toolParams),
forceMessageTool: shouldForceCopilotMessageTool(attemptParams),
isRawModelRun: isCopilotRawModelRun(attemptParams),
modelToolsEnabled: true,
prompt: attemptParams.prompt,
runId: attemptParams.runId,
runtimeToolAllowlist: effectiveToolPlan.runtimeToolAllowlist,
sessionId: input.sessionId,
sessionKey: attemptParams.sandboxSessionKey ?? attemptParams.sessionKey ?? input.sessionKey,
sourceReplyDeliveryMode: attemptParams.sourceReplyDeliveryMode,
toolsAllow: attemptParams.toolsAllow,
});
const toolOptions = buildOpenClawCodingToolsOptions(
input,
{
...effectiveToolPlan,
runtimeToolAllowlist: toolSurfaceRuntime.runtimeToolAllowlist,
},
toolSurfaceRuntime,
);
const toolOptions = buildOpenClawCodingToolsOptions(input, effectiveToolPlan);
let sourceTools: unknown;
try {
@@ -226,19 +196,13 @@ export async function createCopilotToolBridge(
);
}
const allowedSourceTools = filterCopilotToolsForAllowlist(
sourceTools as AnyAgentTool[],
toolSurfaceRuntime.runtimeToolAllowlist,
);
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
const plannedTools = filterCopilotToolsForConstructionPlan(
compactedTools.tools,
sourceTools as AnyAgentTool[],
effectiveToolPlan.codingToolConstructionPlan,
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
);
const filteredTools = filterCopilotToolsForAllowlist(
plannedTools,
toolSurfaceRuntime.runtimeToolAllowlist,
effectiveToolPlan.runtimeToolAllowlist,
);
// Run duplicate detection after filtering so a duplicate in a
@@ -250,7 +214,6 @@ export async function createCopilotToolBridge(
}
return {
cleanup: toolSurfaceRuntime.cleanup,
sdkTools: filteredTools.map((sourceTool) =>
convertOpenClawToolToSdkTool(sourceTool, {
abortSignal: input.abortSignal,
@@ -288,7 +251,6 @@ export async function createCopilotToolBridge(
function buildOpenClawCodingToolsOptions(
input: CopilotToolBridgeInput,
toolPlan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>,
toolSurfaceRuntime?: ReturnType<typeof createAgentHarnessToolSurfaceRuntime>,
): OpenClawCodingToolsOptions {
const a = input.attemptParams ?? ({} as CopilotToolAttemptParams);
@@ -377,14 +339,11 @@ function buildOpenClawCodingToolsOptions(
// `resolveSandboxContext`).
sandbox,
spawnWorkspaceDir,
config: toolSurfaceRuntime?.config ?? a.config,
config: a.config,
abortSignal: input.abortSignal,
modelProvider: input.modelProvider,
modelId: input.modelId,
includeCoreTools: toolPlan.includeCoreTools,
includeToolSearchControls: toolSurfaceRuntime?.includeToolSearchControls,
toolSearchCatalogRef: toolSurfaceRuntime?.toolSearchCatalogRef,
toolSearchCatalogExecutor: toolSurfaceRuntime?.toolSearchCatalogExecutor,
runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
modelCompat,
@@ -616,63 +575,6 @@ export function convertOpenClawToolToSdkTool(
};
}
async function executeCatalogTool(
input: CopilotToolBridgeInput,
params: CatalogExecuteParams,
): Promise<Awaited<ReturnType<AnyAgentTool["execute"]>>> {
const sourceTool = params.tool as AnyAgentTool;
const startedAt = Date.now();
let preparedArgs: unknown = params.input;
try {
preparedArgs = sourceTool.prepareArguments
? sourceTool.prepareArguments(params.input)
: params.input;
const result = await sourceTool.execute(
params.toolCallId,
preparedArgs,
params.signal ?? input.abortSignal,
params.onUpdate,
);
const sanitizedResult = sanitizeToolResult(result);
const isError = isToolResultError(sanitizedResult);
input.attemptParams?.onAgentToolResult?.({
toolName: params.toolName,
result: sanitizedResult,
isError,
});
await input.onToolCompleted?.({
toolName: params.toolName,
toolCallId: params.toolCallId,
args: toToolStartArgs(preparedArgs),
result: sanitizedResult,
...(isError
? { error: extractToolErrorMessage(sanitizedResult) ?? "tool returned an error" }
: {}),
startedAt,
});
return result;
} catch (error: unknown) {
const message = toError(error).message;
const failure = sanitizeToolResult({
content: [{ type: "text", text: message }],
details: { status: "failed", error: message },
});
input.attemptParams?.onAgentToolResult?.({
toolName: params.toolName,
result: failure,
isError: true,
});
await input.onToolCompleted?.({
toolName: params.toolName,
toolCallId: params.toolCallId,
args: toToolStartArgs(preparedArgs),
error: message,
startedAt,
});
throw error;
}
}
function toToolStartArgs(args: unknown): Record<string, unknown> {
return args && typeof args === "object" && !Array.isArray(args)
? (args as Record<string, unknown>)
@@ -810,16 +712,11 @@ function filterCopilotToolsForAllowlist<T extends { name: string }>(
function filterCopilotToolsForConstructionPlan<T extends { name: string }>(
tools: T[],
plan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>["codingToolConstructionPlan"],
options: { preserveToolNames?: readonly string[] } = {},
): T[] {
if (plan.includeBaseCodingTools && plan.includeShellTools) {
return tools;
}
const preserveToolNames = new Set(options.preserveToolNames);
return tools.filter((tool) => {
if (preserveToolNames.has(tool.name)) {
return true;
}
if (!plan.includeBaseCodingTools && BASE_COPILOT_CODING_TOOL_NAMES.has(tool.name)) {
return false;
}

View File

@@ -1,121 +0,0 @@
// Copilot tests cover SDK ask_user bridge behavior.
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it, vi } from "vitest";
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
function createParams(): EmbeddedRunAttemptParams {
return {
sessionId: "session-1",
sessionKey: "agent:main:session-1",
onBlockReply: vi.fn(),
} as unknown as EmbeddedRunAttemptParams;
}
function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string {
const onBlockReply = params.onBlockReply;
if (!onBlockReply) {
throw new Error("Expected onBlockReply callback");
}
const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0];
if (typeof payload?.text !== "string") {
throw new Error("Expected first block reply text");
}
return payload.text;
}
describe("Copilot user input bridge", () => {
it("prompts through OpenClaw and resolves the SDK request from the next queued message", async () => {
const params = createParams();
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
const response = bridge.onUserInputRequest(
{
question: "Pick a mode",
choices: ["Fast", "Deep"],
allowFreeform: false,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
expect(expectFirstBlockReplyText(params)).toContain("Pick a mode");
expect(bridge.handleQueuedMessage("2")).toBe(true);
await expect(response).resolves.toEqual({ answer: "Deep", wasFreeform: false });
});
it("returns free-form answers when Copilot allows them", async () => {
const params = createParams();
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
const response = bridge.onUserInputRequest(
{
question: "Which branch?",
allowFreeform: true,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
expect(bridge.handleQueuedMessage("fix/harness-parity")).toBe(true);
await expect(response).resolves.toEqual({
answer: "fix/harness-parity",
wasFreeform: true,
});
});
it("escapes SDK-controlled prompt text before channel delivery", async () => {
const params = createParams();
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
void bridge.onUserInputRequest(
{
question: "Pick [trusted](https://evil) <@U123> @here\u202e",
choices: ["One @everyone", "Two `code`"],
allowFreeform: false,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
const text = expectFirstBlockReplyText(params);
expect(text).not.toContain("@here");
expect(text).not.toContain("@everyone");
expect(text).not.toContain("<@U123>");
expect(text).not.toContain("[trusted](https://evil)");
expect(text).not.toContain("`code`");
expect(text).toContain("\uff20here");
expect(text).toContain("\uff3btrusted\uff3d");
});
it("rejects queued messages when no ask_user request is pending", () => {
const bridge = createCopilotUserInputBridge({ paramsForRun: createParams() });
expect(bridge.handleQueuedMessage("late")).toBe(false);
});
it("resolves pending requests with an empty answer when aborted", async () => {
const params = createParams();
const controller = new AbortController();
const bridge = createCopilotUserInputBridge({
paramsForRun: params,
signal: controller.signal,
});
const response = bridge.onUserInputRequest(
{
question: "Continue?",
choices: ["Yes", "No"],
allowFreeform: false,
},
{ sessionId: "sdk-session-1" },
);
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
controller.abort();
await expect(response).resolves.toEqual({ answer: "", wasFreeform: true });
expect(bridge.handleQueuedMessage("1")).toBe(false);
});
});

View File

@@ -1,161 +0,0 @@
import type { SessionConfig } from "@github/copilot-sdk";
import {
buildAgentHarnessUserInputAnswers,
deliverAgentHarnessUserInputPrompt,
embeddedAgentLog,
type AgentHarnessUserInputQuestion,
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
type PendingCopilotUserInput = {
question: AgentHarnessUserInputQuestion;
resolve: (value: CopilotUserInputResponse) => void;
cleanup: () => void;
};
type CopilotUserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
type CopilotUserInputRequest = Parameters<CopilotUserInputHandler>[0];
type CopilotUserInputResponse = Awaited<ReturnType<CopilotUserInputHandler>>;
type CopilotUserInputBridge = {
onUserInputRequest: CopilotUserInputHandler;
handleQueuedMessage: (text: string) => boolean;
cancelPending: () => void;
};
const COPILOT_USER_INPUT_QUESTION_ID = "answer";
export function createCopilotUserInputBridge(params: {
paramsForRun: EmbeddedRunAttemptParams;
signal?: AbortSignal;
}): CopilotUserInputBridge {
let pending: PendingCopilotUserInput | undefined;
const resolvePending = (value: CopilotUserInputResponse) => {
const current = pending;
if (!current) {
return;
}
pending = undefined;
current.cleanup();
current.resolve(value);
};
return {
onUserInputRequest(request) {
const question = toQuestion(request);
resolvePending(emptyCopilotUserInputResponse());
return new Promise<CopilotUserInputResponse>((resolve) => {
const abortListener = () => resolvePending(emptyCopilotUserInputResponse());
const cleanup = () => params.signal?.removeEventListener("abort", abortListener);
pending = { question, resolve, cleanup };
params.signal?.addEventListener("abort", abortListener, { once: true });
if (params.signal?.aborted) {
resolvePending(emptyCopilotUserInputResponse());
return;
}
void deliverAgentHarnessUserInputPrompt(params.paramsForRun, [question], {
intro: "Copilot needs input:",
formatText: formatCopilotDisplayText,
}).catch((error: unknown) => {
embeddedAgentLog.warn("failed to deliver copilot user input prompt", { error });
});
});
},
handleQueuedMessage(text) {
const current = pending;
if (!current) {
return false;
}
resolvePending(buildCopilotUserInputResponse(current.question, text));
return true;
},
cancelPending() {
resolvePending(emptyCopilotUserInputResponse());
},
};
}
function toQuestion(request: CopilotUserInputRequest): AgentHarnessUserInputQuestion {
return {
id: COPILOT_USER_INPUT_QUESTION_ID,
header: "Copilot needs input",
question: request.question,
isOther: request.allowFreeform !== false,
isSecret: false,
options:
request.choices && request.choices.length > 0
? request.choices.map((choice: string) => ({ label: choice }))
: null,
};
}
function buildCopilotUserInputResponse(
question: AgentHarnessUserInputQuestion,
inputText: string,
): CopilotUserInputResponse {
const rawAnswers = buildAgentHarnessUserInputAnswers([question], inputText);
const selected = rawAnswers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? "";
return {
answer: selected,
wasFreeform: !isChoiceAnswer(question, selected),
};
}
function emptyCopilotUserInputResponse(): CopilotUserInputResponse {
return { answer: "", wasFreeform: true };
}
function isChoiceAnswer(question: AgentHarnessUserInputQuestion, answer: string): boolean {
return Boolean(
answer &&
question.options?.some((option) => option.label.toLowerCase() === answer.toLowerCase()),
);
}
function formatCopilotDisplayText(value: string): string {
const safe = sanitizeCopilotDisplayText(value).trim();
return escapeCopilotChatText(safe || "<unknown>");
}
function sanitizeCopilotDisplayText(value: string): string {
let safe = "";
for (const character of value) {
const codePoint = character.codePointAt(0);
safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character;
}
return safe;
}
function escapeCopilotChatText(value: string): string {
return value
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll("@", "\uff20")
.replaceAll("`", "\uff40")
.replaceAll("[", "\uff3b")
.replaceAll("]", "\uff3d")
.replaceAll("(", "\uff08")
.replaceAll(")", "\uff09")
.replaceAll("*", "\u2217")
.replaceAll("_", "\uff3f")
.replaceAll("~", "\uff5e")
.replaceAll("|", "\uff5c");
}
function isUnsafeDisplayCodePoint(codePoint: number): boolean {
return (
codePoint <= 0x001f ||
(codePoint >= 0x007f && codePoint <= 0x009f) ||
codePoint === 0x00ad ||
codePoint === 0x061c ||
codePoint === 0x180e ||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
(codePoint >= 0x2060 && codePoint <= 0x206f) ||
codePoint === 0xfeff ||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
(codePoint >= 0xe0000 && codePoint <= 0xe007f)
);
}

View File

@@ -73,8 +73,6 @@ const GEMINI_FRESHNESS_DAYS: Record<GeminiFreshness, number> = {
year: 365,
};
const GEMINI_DAY_FRESHNESS_HINT = "Prioritize web sources published in the last 24 hours.";
// Gemini's google_search.time_range_filter accepts second-precision RFC 3339
// only. Despite the underlying google.protobuf.Timestamp type accepting "0, 3,
// 6 or 9 fractional digits", the Search grounding endpoint rejects any
@@ -101,18 +99,11 @@ function freshnessStartTime(freshness: GeminiFreshness, now: Date): string {
return toGeminiTimeRangeTimestamp(start);
}
function queryWithSoftFreshness(query: string, freshness?: "day"): string {
if (freshness !== "day") {
return query;
}
return `${query}\n\nSearch recency instruction: ${GEMINI_DAY_FRESHNESS_HINT} If no matching recent sources are available, state that limitation and use the most relevant available sources.`;
}
function resolveGeminiTimeRangeFilter(
args: Record<string, unknown>,
now = new Date(),
):
| { timeRangeFilter?: GeminiTimeRangeFilter; freshness?: "day" }
| { timeRangeFilter?: GeminiTimeRangeFilter }
| {
error:
| "invalid_freshness"
@@ -142,13 +133,6 @@ function resolveGeminiTimeRangeFilter(
const { freshness, dateAfter, dateBefore } = parsedTimeFilters;
if (freshness) {
// Gemini rejects 24-hour google_search.timeRangeFilter windows, while
// wider freshness windows still preserve the hard grounding contract.
if (freshness === "day") {
return {
freshness,
};
}
return {
timeRangeFilter: {
startTime: freshnessStartTime(freshness, now),
@@ -337,7 +321,6 @@ export async function executeGeminiSearch(
resolveSearchCount(count, DEFAULT_SEARCH_COUNT),
baseUrl,
model,
timeRange.freshness,
timeRange.timeRangeFilter?.startTime,
timeRange.timeRangeFilter?.endTime,
]);
@@ -348,7 +331,7 @@ export async function executeGeminiSearch(
const start = Date.now();
const result = await runGeminiSearch({
query: queryWithSoftFreshness(query, timeRange.freshness),
query,
apiKey,
baseUrl,
model,

View File

@@ -40,8 +40,7 @@ const GEMINI_TOOL_PARAMETERS = {
language: { type: "string", description: "Not supported by Gemini." },
freshness: {
type: "string",
description:
"Filter Gemini search freshness: week, month, and year use hard Google Search time ranges; day prioritizes the last 24 hours as a recency hint.",
description: "Limit Google Search grounding to recent results: day, week, month, or year.",
},
date_after: {
type: "string",

View File

@@ -10,9 +10,10 @@ type TestModelProviderConfig = NonNullable<
function installGeminiFetch() {
const mockFetch = vi.fn((_input?: RequestInfo | URL, _init?: RequestInit) =>
Promise.resolve(
new Response(
JSON.stringify({
Promise.resolve({
ok: true,
json: () =>
Promise.resolve({
candidates: [
{
content: { parts: [{ text: "Grounded answer" }] },
@@ -22,8 +23,7 @@ function installGeminiFetch() {
},
],
}),
),
),
} as Response),
);
vi.stubGlobal("fetch", withFetchPreconnect(mockFetch));
return mockFetch;
@@ -66,7 +66,6 @@ function getGeminiFetchUrl(mockFetch: ReturnType<typeof installGeminiFetch>): st
}
function parseGeminiFetchBody(mockFetch: ReturnType<typeof installGeminiFetch>): {
contents?: Array<{ parts?: Array<{ text?: string }> }>;
tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>;
} {
const [, init] = requireFirstGeminiFetchCall(mockFetch);
@@ -75,7 +74,6 @@ function parseGeminiFetchBody(mockFetch: ReturnType<typeof installGeminiFetch>):
throw new Error("Expected Gemini fetch body string");
}
return JSON.parse(body) as {
contents?: Array<{ parts?: Array<{ text?: string }> }>;
tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>;
};
}
@@ -479,37 +477,10 @@ describe("google web search provider", () => {
);
});
it("uses a soft recency hint for Gemini day freshness shortcuts instead of a 24-hour range", async () => {
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: "AIza-plugin-test",
},
},
},
},
},
},
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "latest ai news timestamp precision", freshness: "pd" });
const body = parseGeminiFetchBody(mockFetch);
expect(body.tools?.[0]?.google_search?.timeRangeFilter).toBeUndefined();
expect(body.contents?.[0]?.parts?.[0]?.text).toContain(
"Prioritize web sources published in the last 24 hours.",
);
});
it("preserves hard Gemini time ranges for wider freshness values", async () => {
it("passes freshness to Gemini Google Search grounding as a time range", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
// Use a wall-clock-realistic moment with non-zero milliseconds; the helper
// must strip them to avoid Gemini's "Granularity of nano is not supported".
vi.setSystemTime(new Date("2026-04-15T12:00:00.123Z"));
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
@@ -533,68 +504,13 @@ describe("google web search provider", () => {
await tool?.execute({ query: "latest ai news timestamp precision", freshness: "week" });
const body = parseGeminiFetchBody(mockFetch);
expect(body.contents?.[0]?.parts?.[0]?.text).toBe("latest ai news timestamp precision");
expect(body.tools?.[0]?.google_search?.timeRangeFilter).toEqual({
startTime: "2026-04-08T12:00:00Z",
endTime: "2026-04-15T12:00:00Z",
});
});
it("partitions Gemini cache entries for soft day freshness, hard week freshness, and no freshness", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
vi.setSystemTime(new Date("2026-04-15T12:00:00.123Z"));
const mockFetch = installGeminiFetch();
const provider = createGeminiWebSearchProvider();
const tool = provider.createTool({
config: {
plugins: {
entries: {
google: {
config: {
webSearch: {
apiKey: "AIza-plugin-test",
},
},
},
},
},
},
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "same query cache partition", freshness: "day" });
await tool?.execute({ query: "same query cache partition", freshness: "week" });
await tool?.execute({ query: "same query cache partition" });
const postCalls = mockFetch.mock.calls.filter(([, init]) => typeof init?.body === "string");
expect(postCalls).toHaveLength(3);
const parsePostedBody = (call: (typeof postCalls)[number] | undefined) => {
const body = call?.[1]?.body;
if (typeof body !== "string") {
throw new Error("Expected Gemini fetch body to be a string");
}
return JSON.parse(body) as {
contents?: Array<{ parts?: Array<{ text?: string }> }>;
tools?: Array<{ google_search?: { timeRangeFilter?: unknown } }>;
};
};
const firstBody = parsePostedBody(postCalls[0]);
const secondBody = parsePostedBody(postCalls[1]);
const thirdBody = parsePostedBody(postCalls[2]);
expect(firstBody.tools?.[0]?.google_search?.timeRangeFilter).toBeUndefined();
expect(firstBody.contents?.[0]?.parts?.[0]?.text).toContain(
"Prioritize web sources published in the last 24 hours.",
);
expect(secondBody.tools?.[0]?.google_search?.timeRangeFilter).toEqual({
startTime: "2026-04-08T12:00:00Z",
endTime: "2026-04-15T12:00:00Z",
});
expect(secondBody.contents?.[0]?.parts?.[0]?.text).toBe("same query cache partition");
expect(thirdBody.tools?.[0]?.google_search?.timeRangeFilter).toBeUndefined();
expect(thirdBody.contents?.[0]?.parts?.[0]?.text).toBe("same query cache partition");
});
it("strips sub-second precision from date-range timestamps so Gemini accepts them", async () => {
it("strips sub-second precision from freshness timestamps so Gemini accepts them", async () => {
vi.useFakeTimers({ toFake: ["Date"] });
// "now" with non-zero milliseconds. Without stripping, toISOString() emits
// "2026-04-15T12:00:00.123Z", which Gemini's google_search.time_range_filter
@@ -619,7 +535,7 @@ describe("google web search provider", () => {
searchConfig: { provider: "gemini" },
});
await tool?.execute({ query: "latest ai news", date_after: "2026-04-01" });
await tool?.execute({ query: "latest ai news", freshness: "week" });
const body = parseGeminiFetchBody(mockFetch);
const filter = body.tools?.[0]?.google_search?.timeRangeFilter as
@@ -628,7 +544,7 @@ describe("google web search provider", () => {
expect(filter?.startTime).not.toMatch(/\.\d+Z$/);
expect(filter?.endTime).not.toMatch(/\.\d+Z$/);
expect(filter).toEqual({
startTime: "2026-04-01T00:00:00Z",
startTime: "2026-04-08T12:00:00Z",
endTime: "2026-04-15T12:00:00Z",
});
});

View File

@@ -11,7 +11,9 @@ openclaw plugins install @openclaw/llama-cpp-provider
```
Restart the Gateway after installing or updating the plugin. Use Node 24 for
native installs and updates.
native installs and updates. Source checkouts leave the native build unapproved
by default; run `pnpm approve-builds` and `pnpm rebuild node-llama-cpp` before
using local GGUF embeddings from source.
## Configure

View File

@@ -7,7 +7,7 @@
"": {
"name": "@openclaw/llama-cpp-provider",
"version": "2026.6.9",
"optionalDependencies": {
"dependencies": {
"node-llama-cpp": "3.18.1"
}
},
@@ -16,7 +16,6 @@
"resolved": "https://registry.npmjs.org/@huggingface/jinja/-/jinja-0.5.9.tgz",
"integrity": "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -26,7 +25,6 @@
"resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz",
"integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==",
"license": "ISC",
"optional": true,
"dependencies": {
"minipass": "^7.0.4"
},
@@ -39,7 +37,6 @@
"resolved": "https://registry.npmjs.org/@kwsites/file-exists/-/file-exists-1.1.1.tgz",
"integrity": "sha512-m9/5YGR18lIwxSFDwfE3oA7bWuq9kdau6ugN4H2rJeyhFQZcG9AgSHkQtSD15a8WvTgfz9aikZMrKPHvbpqFiw==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "^4.1.1"
}
@@ -48,8 +45,7 @@
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@kwsites/promise-deferred/-/promise-deferred-1.1.1.tgz",
"integrity": "sha512-GaHYm+c0O9MjZRu0ongGBRbinu8gVAMd2UZjji6jVmqKtZluZnptXGWhz1E8j8D2HJ3f/yMxKAUC0b+57wncIw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/@node-llama-cpp/linux-arm64": {
"version": "3.18.1",
@@ -415,15 +411,13 @@
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@simple-git/args-pathspec/-/args-pathspec-1.0.3.tgz",
"integrity": "sha512-ngJMaHlsWDTfjyq9F3VIQ8b7NXbBLq5j9i5bJ6XLYtD6qlDXT7fdKY2KscWWUF8t18xx052Y/PUO1K1TRc9yKA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/@simple-git/argv-parser": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@simple-git/argv-parser/-/argv-parser-1.1.1.tgz",
"integrity": "sha512-Q9lBcfQ+VQCpQqGJFHe5yooOS5hGdLFFbJ5R+R5aDsnkPCahtn1hSkMcORX65J2Z5lxSkD0lQorMsncuBQxYUw==",
"license": "MIT",
"optional": true,
"dependencies": {
"@simple-git/args-pathspec": "^1.0.3"
}
@@ -433,7 +427,6 @@
"resolved": "https://registry.npmjs.org/@tinyhttp/content-disposition/-/content-disposition-2.2.4.tgz",
"integrity": "sha512-5Kc5CM2Ysn3vTTArBs2vESUt0AQiWZA86yc1TI3B+lxXmtEq133C1nxXNOgnzhrivdPZIh3zLj5gDnZjoLL5GA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12.17.0"
},
@@ -447,7 +440,6 @@
"resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-6.2.1.tgz",
"integrity": "sha512-4nJ3yixlEthEJ9Rk4vPcdBRkZvQZlYyu8j4/Mqz5sgIkddmEnH2Yj2ZrnP9S3tQOvSNRUIgVNF/1yPpRAGNRig==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14.16"
},
@@ -460,7 +452,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.2.2.tgz",
"integrity": "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
@@ -473,7 +464,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-6.2.3.tgz",
"integrity": "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
@@ -486,7 +476,6 @@
"resolved": "https://registry.npmjs.org/async-retry/-/async-retry-1.3.3.tgz",
"integrity": "sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==",
"license": "MIT",
"optional": true,
"dependencies": {
"retry": "0.13.1"
}
@@ -496,7 +485,6 @@
"resolved": "https://registry.npmjs.org/bytes/-/bytes-3.1.2.tgz",
"integrity": "sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 0.8"
}
@@ -506,7 +494,6 @@
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz",
"integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
@@ -518,15 +505,13 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/chmodrp/-/chmodrp-1.0.2.tgz",
"integrity": "sha512-TdngOlFV1FLTzU0o1w8MB6/BFywhtLC0SzRTGJU7T9lmdjlCWeMRt1iVo0Ki+ldwNk0BqNiKoc8xpLZEQ8mY1w==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/chownr": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz",
"integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -542,7 +527,6 @@
}
],
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -552,7 +536,6 @@
"resolved": "https://registry.npmjs.org/cli-cursor/-/cli-cursor-5.0.0.tgz",
"integrity": "sha512-aCj4O5wKyszjMmDT4tZj93kxyydN/K5zPWSCe6/0AV/AA1pqe5ZBIw0a2ZfPQV7lL5/yb5HsUreJ6UFAF1tEQw==",
"license": "MIT",
"optional": true,
"dependencies": {
"restore-cursor": "^5.0.0"
},
@@ -568,7 +551,6 @@
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-2.9.2.tgz",
"integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
},
@@ -581,7 +563,6 @@
"resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz",
"integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==",
"license": "ISC",
"optional": true,
"dependencies": {
"string-width": "^4.2.0",
"strip-ansi": "^6.0.1",
@@ -596,7 +577,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -606,7 +586,6 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -616,7 +595,6 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -631,7 +609,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -644,7 +621,6 @@
"resolved": "https://registry.npmjs.org/cmake-js/-/cmake-js-8.0.0.tgz",
"integrity": "sha512-YbUP88RDwCvoQkZhRtGURYm9RIpWdtvZuhT87fKNoLjk8kIFIFeARpKfuZQGdwfH99GZpUmqSfcDrK62X7lTgg==",
"license": "MIT",
"optional": true,
"dependencies": {
"debug": "^4.4.3",
"fs-extra": "^11.3.3",
@@ -668,7 +644,6 @@
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
"integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-name": "~1.1.4"
},
@@ -680,15 +655,13 @@
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz",
"integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/commander": {
"version": "10.0.1",
"resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz",
"integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=14"
}
@@ -698,7 +671,6 @@
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"license": "MIT",
"optional": true,
"dependencies": {
"path-key": "^3.1.0",
"shebang-command": "^2.0.0",
@@ -712,15 +684,13 @@
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz",
"integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/cross-spawn/node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
"integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==",
"license": "ISC",
"optional": true,
"dependencies": {
"isexe": "^2.0.0"
},
@@ -736,7 +706,6 @@
"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
"integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
"license": "MIT",
"optional": true,
"dependencies": {
"ms": "^2.1.3"
},
@@ -754,7 +723,6 @@
"resolved": "https://registry.npmjs.org/deep-extend/-/deep-extend-0.6.0.tgz",
"integrity": "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=4.0.0"
}
@@ -763,15 +731,13 @@
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
"integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/env-var": {
"version": "7.5.0",
"resolved": "https://registry.npmjs.org/env-var/-/env-var-7.5.0.tgz",
"integrity": "sha512-mKZOzLRN0ETzau2W2QXefbFjo5EF4yWq28OyKb9ICdeNhHJlOE/pHHnz4hdYJ9cNZXcJHo5xN4OT4pzuSHSNvA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=10"
}
@@ -781,7 +747,6 @@
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
"integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=6"
}
@@ -790,15 +755,13 @@
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.4.tgz",
"integrity": "sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/filename-reserved-regex": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/filename-reserved-regex/-/filename-reserved-regex-3.0.0.tgz",
"integrity": "sha512-hn4cQfU6GOT/7cFHXBqeBg2TbrMBgdD0kcjLhvSQYYwm3s4B6cjvBfb7nBALJLAXqmU5xajSa7X2NnUud/VCdw==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
},
@@ -811,7 +774,6 @@
"resolved": "https://registry.npmjs.org/filenamify/-/filenamify-6.0.0.tgz",
"integrity": "sha512-vqIlNogKeyD3yzrm0yhRMQg8hOVwYcYRfjEoODd49iCprMn4HL85gK3HcykQE53EPIpX3HcAbGA5ELQv216dAQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"filename-reserved-regex": "^3.0.0"
},
@@ -827,7 +789,6 @@
"resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-11.3.5.tgz",
"integrity": "sha512-eKpRKAovdpZtR1WopLHxlBWvAgPny3c4gX1G5Jhwmmw4XJj0ifSD5qB5TOo8hmA0wlRKDAOAhEE1yVPgs6Fgcg==",
"license": "MIT",
"optional": true,
"dependencies": {
"graceful-fs": "^4.2.0",
"jsonfile": "^6.0.1",
@@ -842,7 +803,6 @@
"resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz",
"integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==",
"license": "ISC",
"optional": true,
"engines": {
"node": "6.* || 8.* || >= 10.*"
}
@@ -852,7 +812,6 @@
"resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz",
"integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -864,15 +823,13 @@
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
"integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/ignore": {
"version": "7.0.5",
"resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz",
"integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 4"
}
@@ -881,15 +838,13 @@
"version": "1.3.8",
"resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz",
"integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/ipull": {
"version": "3.9.5",
"resolved": "https://registry.npmjs.org/ipull/-/ipull-3.9.5.tgz",
"integrity": "sha512-5w/yZB5lXmTfsvNawmvkCjYo4SJNuKQz/av8TC1UiOyfOHyaM+DReqbpU2XpWYfmY+NIUbRRH8PUAWsxaS+IfA==",
"license": "MIT",
"optional": true,
"dependencies": {
"@tinyhttp/content-disposition": "^2.2.0",
"async-retry": "^1.3.3",
@@ -929,15 +884,13 @@
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-2.1.0.tgz",
"integrity": "sha512-AnrXnE2/OF9PHCyFg0RSqsnQTzV991XaZA/buhFDoc58xU7rhSCDgCz/09Lqpsn4MpoPHt7TRAXV1kWZypFVsA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/ipull/node_modules/parse-ms": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-3.0.0.tgz",
"integrity": "sha512-Tpb8Z7r7XbbtBTrM9UhpkzzaMrqA2VXMT3YChzYltwV3P3pM6t8wl7TvpMnSTosz1aQAdVib7kdoys7vYOPerw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
@@ -950,7 +903,6 @@
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-8.0.0.tgz",
"integrity": "sha512-ASJqOugUF1bbzI35STMBUpZqdfYKlJugy6JBziGi2EE+AL5JPJGSzvpeVXojxrr0ViUYoToUjb5kjSEGf7Y83Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"parse-ms": "^3.0.0"
},
@@ -966,7 +918,6 @@
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-7.1.2.tgz",
"integrity": "sha512-iOBWFgUX7caIZiuutICxVgX1SdxwAVFFKwt1EvMYYec/NWO5meOJ6K5uQxhrYBdQJne4KxiqZc+KptFOWFSI9w==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-styles": "^6.2.1",
"is-fullwidth-code-point": "^5.0.0"
@@ -983,7 +934,6 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-5.1.0.tgz",
"integrity": "sha512-5XHYaSyiqADb4RnZ1Bdad6cPp8Toise4TzEjcOYDHZkTCbKgiUl7WTUCpNWHuxmDt91wnsZBc9xinNzopv3JMQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"get-east-asian-width": "^1.3.1"
},
@@ -999,7 +949,6 @@
"resolved": "https://registry.npmjs.org/is-interactive/-/is-interactive-2.0.0.tgz",
"integrity": "sha512-qP1vozQRI+BMOPcjFzrjXuQvdak2pHNUMZoeG2eRbiSqyvbEf/wQtEOTOX1guk6E3t36RkaqiSt8A/6YElNxLQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=12"
},
@@ -1012,7 +961,6 @@
"resolved": "https://registry.npmjs.org/is-unicode-supported/-/is-unicode-supported-2.1.0.tgz",
"integrity": "sha512-mE00Gnza5EEB3Ds0HfMyllZzbBrmLOX3vfWoj9A9PEnTfratQ/BcaJOuMhnkhjXvb2+FkY3VuHqtAGpTPmglFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -1025,7 +973,6 @@
"resolved": "https://registry.npmjs.org/isexe/-/isexe-4.0.0.tgz",
"integrity": "sha512-FFUtZMpoZ8RqHS3XeXEmHWLA4thH+ZxCv2lOiPIn1Xc7CxrqhWzNSDzD+/chS/zbYezmiwWLdQC09JdQKmthOw==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=20"
}
@@ -1035,7 +982,6 @@
"resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-6.2.1.tgz",
"integrity": "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"universalify": "^2.0.0"
},
@@ -1047,22 +993,19 @@
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/lifecycle-utils/-/lifecycle-utils-3.1.1.tgz",
"integrity": "sha512-gNd3OvhFNjHykJE3uGntz7UuPzWlK9phrIdXxU9Adis0+ExkwnZibfxCJWiWWZ+a6VbKiZrb+9D9hCQWd4vjTg==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/lodash.debounce": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
"integrity": "sha512-FT1yDzDYEoYWhnSGnpE/4Kj1fLZkDFyqRb7fNt6FdYOSxlUWAtp42Eh6Wb0rGIv/m9Bgo7x4GhQbm5Ys4SG5ow==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/log-symbols": {
"version": "7.0.1",
"resolved": "https://registry.npmjs.org/log-symbols/-/log-symbols-7.0.1.tgz",
"integrity": "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg==",
"license": "MIT",
"optional": true,
"dependencies": {
"is-unicode-supported": "^2.0.0",
"yoctocolors": "^2.1.1"
@@ -1079,7 +1022,6 @@
"resolved": "https://registry.npmjs.org/lowdb/-/lowdb-7.0.1.tgz",
"integrity": "sha512-neJAj8GwF0e8EpycYIDFqEPcx9Qz4GUho20jWFR7YiFeXzF1YMLdxB36PypcTSPMA+4+LvgyMacYhlr18Zlymw==",
"license": "MIT",
"optional": true,
"dependencies": {
"steno": "^4.0.2"
},
@@ -1095,7 +1037,6 @@
"resolved": "https://registry.npmjs.org/mimic-function/-/mimic-function-5.0.1.tgz",
"integrity": "sha512-VP79XUPxV2CigYP3jWwAUFSku2aKqBH7uTAapFWCBqutsbmDo96KY5o8uh6U+/YSIn5OxJnXp73beVkpqMIGhA==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -1108,7 +1049,6 @@
"resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz",
"integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==",
"license": "MIT",
"optional": true,
"funding": {
"url": "https://github.com/sponsors/ljharb"
}
@@ -1118,7 +1058,6 @@
"resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.3.tgz",
"integrity": "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=16 || 14 >=14.17"
}
@@ -1128,7 +1067,6 @@
"resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz",
"integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==",
"license": "MIT",
"optional": true,
"dependencies": {
"minipass": "^7.1.2"
},
@@ -1140,8 +1078,7 @@
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/nanoid": {
"version": "5.1.11",
@@ -1154,7 +1091,6 @@
}
],
"license": "MIT",
"optional": true,
"bin": {
"nanoid": "bin/nanoid.js"
},
@@ -1167,7 +1103,6 @@
"resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-8.8.0.tgz",
"integrity": "sha512-c5Ko1fZJIJmzhFIkhRN76WTq+fC6tWnGy9CXA0fA+XygsWZmEwG8vmbkNqxMyoaa0Tin4djul49NzdVcJJcjeA==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^18 || ^20 || >= 21"
}
@@ -1176,8 +1111,7 @@
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/node-api-headers/-/node-api-headers-1.9.0.tgz",
"integrity": "sha512-2oNILP4jXwRB4ywnYKjVk1YyJ96n2D4EOVJO6S3oYZ5PtbJrw3Yt9TpAuX3nBLMuzn74rnfGQrv13pS9vC+YiA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/node-llama-cpp": {
"version": "3.18.1",
@@ -1185,7 +1119,6 @@
"integrity": "sha512-w0zfuy/IKS2fhrbed5SylZDXJHTVz4HnkwZ4UrFPgSNwJab3QIPwIl4lyCKHHy9flLrtxsAuV5kXfH3HZ6bb8w==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@huggingface/jinja": "^0.5.6",
"async-retry": "^1.3.3",
@@ -1256,7 +1189,6 @@
"resolved": "https://registry.npmjs.org/onetime/-/onetime-7.0.0.tgz",
"integrity": "sha512-VXJjc87FScF88uafS3JllDgvAm+c/Slfz06lorj2uAY34rlUu0Nt+v8wreiImcrgAjjIHp1rXpTDlLOGw29WwQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"mimic-function": "^5.0.0"
},
@@ -1272,7 +1204,6 @@
"resolved": "https://registry.npmjs.org/ora/-/ora-9.4.0.tgz",
"integrity": "sha512-84cglkRILFxdtA8hAvLNdMrtBpPNBTrQ9/ulg0FA7xLMnD6mifv+enAIeRmvtv+WgdCE+LPGOfQmtJRrVaIVhQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"chalk": "^5.6.2",
"cli-cursor": "^5.0.0",
@@ -1295,7 +1226,6 @@
"resolved": "https://registry.npmjs.org/cli-spinners/-/cli-spinners-3.4.0.tgz",
"integrity": "sha512-bXfOC4QcT1tKXGorxL3wbJm6XJPDqEnij2gQ2m7ESQuE+/z9YFIWnl/5RpTiKWbMq3EVKR4fRLJGn6DVfu0mpw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18.20"
},
@@ -1308,7 +1238,6 @@
"resolved": "https://registry.npmjs.org/parse-ms/-/parse-ms-4.0.0.tgz",
"integrity": "sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -1321,7 +1250,6 @@
"resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz",
"integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1331,7 +1259,6 @@
"resolved": "https://registry.npmjs.org/pretty-bytes/-/pretty-bytes-6.1.1.tgz",
"integrity": "sha512-mQUvGU6aUFQ+rNvTIAcZuWGRT9a6f6Yrg9bHs4ImKF+HZCEK+plBvnAZYSIQztknZF2qnzNtr6F8s0+IuptdlQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": "^14.13.1 || >=16.0.0"
},
@@ -1344,7 +1271,6 @@
"resolved": "https://registry.npmjs.org/pretty-ms/-/pretty-ms-9.3.0.tgz",
"integrity": "sha512-gjVS5hOP+M3wMm5nmNOucbIrqudzs9v/57bWRHQWLYklXqoXKrVfYW2W9+glfGsqtPgpiz5WwyEEB+ksXIx3gQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"parse-ms": "^4.0.0"
},
@@ -1360,7 +1286,6 @@
"resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz",
"integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==",
"license": "MIT",
"optional": true,
"dependencies": {
"graceful-fs": "^4.2.4",
"retry": "^0.12.0",
@@ -1372,7 +1297,6 @@
"resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz",
"integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 4"
}
@@ -1382,7 +1306,6 @@
"resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz",
"integrity": "sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==",
"license": "(BSD-2-Clause OR MIT OR Apache-2.0)",
"optional": true,
"dependencies": {
"deep-extend": "^0.6.0",
"ini": "~1.3.0",
@@ -1398,7 +1321,6 @@
"resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz",
"integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1408,7 +1330,6 @@
"resolved": "https://registry.npmjs.org/restore-cursor/-/restore-cursor-5.1.0.tgz",
"integrity": "sha512-oMA2dcrw6u0YfxJQXm342bFKX/E4sG9rbTzO9ptUcR/e8A33cHuvStiYOwH7fszkZlZ1z/ta9AAoPk2F4qIOHA==",
"license": "MIT",
"optional": true,
"dependencies": {
"onetime": "^7.0.0",
"signal-exit": "^4.1.0"
@@ -1425,7 +1346,6 @@
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-4.1.0.tgz",
"integrity": "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=14"
},
@@ -1438,7 +1358,6 @@
"resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz",
"integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 4"
}
@@ -1448,7 +1367,6 @@
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz",
"integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
@@ -1461,7 +1379,6 @@
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
"integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==",
"license": "MIT",
"optional": true,
"dependencies": {
"shebang-regex": "^3.0.0"
},
@@ -1474,7 +1391,6 @@
"resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz",
"integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1483,15 +1399,13 @@
"version": "3.0.7",
"resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz",
"integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==",
"license": "ISC",
"optional": true
"license": "ISC"
},
"node_modules/simple-git": {
"version": "3.36.0",
"resolved": "https://registry.npmjs.org/simple-git/-/simple-git-3.36.0.tgz",
"integrity": "sha512-cGQjLjK8bxJw4QuYT7gxHw3/IouVESbhahSsHrX97MzCL1gu2u7oy38W6L2ZIGECEfIBG4BabsWDPjBxJENv9Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"@kwsites/file-exists": "^1.1.1",
"@kwsites/promise-deferred": "^1.1.1",
@@ -1508,15 +1422,13 @@
"version": "9.1.0",
"resolved": "https://registry.npmjs.org/sleep-promise/-/sleep-promise-9.1.0.tgz",
"integrity": "sha512-UHYzVpz9Xn8b+jikYSD6bqvf754xL2uBUzDFwiU6NcdZeifPr6UfgU43xpkPu67VMS88+TI2PSI7Eohgqf2fKA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/slice-ansi": {
"version": "8.0.0",
"resolved": "https://registry.npmjs.org/slice-ansi/-/slice-ansi-8.0.0.tgz",
"integrity": "sha512-stxByr12oeeOyY2BlviTNQlYV5xOj47GirPr4yA1hE9JCtxfQN0+tVbkxwCtYDQWhEKWFHsEK48ORg5jrouCAg==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-styles": "^6.2.3",
"is-fullwidth-code-point": "^5.1.0"
@@ -1533,7 +1445,6 @@
"resolved": "https://registry.npmjs.org/stdin-discarder/-/stdin-discarder-0.3.2.tgz",
"integrity": "sha512-eCPu1qRxPVkl5605OTWF8Wz40b4Mf45NY5LQmVPQ599knfs5QhASUm9GbJ5BDMDOXgrnh0wyEdvzmL//YMlw0A==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -1546,7 +1457,6 @@
"resolved": "https://registry.npmjs.org/stdout-update/-/stdout-update-4.0.1.tgz",
"integrity": "sha512-wiS21Jthlvl1to+oorePvcyrIkiG/6M3D3VTmDUlJm7Cy6SbFhKkAvX+YBuHLxck/tO3mrdpC/cNesigQc3+UQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-escapes": "^6.2.0",
"ansi-styles": "^6.2.1",
@@ -1561,15 +1471,13 @@
"version": "10.6.0",
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-10.6.0.tgz",
"integrity": "sha512-toUI84YS5YmxW219erniWD0CIVOo46xGKColeNQRgOzDorgBi1v4D71/OFzgD9GO2UGKIv1C3Sp8DAn0+j5w7A==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/stdout-update/node_modules/string-width": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/string-width/-/string-width-7.2.0.tgz",
"integrity": "sha512-tsaTIkKW9b4N+AEj+SVA+WhJzV7/zMhcSu78mLKWSk7cXMOSHsBKFWUs0fWwq8QyK3MgJBQRX6Gbi4kYbdvGkQ==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^10.3.0",
"get-east-asian-width": "^1.0.0",
@@ -1587,7 +1495,6 @@
"resolved": "https://registry.npmjs.org/steno/-/steno-4.0.2.tgz",
"integrity": "sha512-yhPIQXjrlt1xv7dyPQg2P17URmXbuM5pdGkpiMB3RenprfiBlvK415Lctfe0eshk90oA7/tNq7WEiMK8RSP39A==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},
@@ -1600,7 +1507,6 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-8.2.1.tgz",
"integrity": "sha512-IIaP0g3iy9Cyy18w3M9YcaDudujEAVHKt3a3QJg1+sr/oX96TbaGUubG0hJyCjCBThFH+tFpcIyoUHUn1ogaLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"get-east-asian-width": "^1.5.0",
"strip-ansi": "^7.1.2"
@@ -1617,7 +1523,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-7.2.0.tgz",
"integrity": "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^6.2.2"
},
@@ -1633,7 +1538,6 @@
"resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-2.0.1.tgz",
"integrity": "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=0.10.0"
}
@@ -1643,7 +1547,6 @@
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.16.tgz",
"integrity": "sha512-56adEpPMouktRlBLXiaYFFzZ/3+JXa8P9n7WbR+ibIjtviN55mEaOkiysCnPnWm+7kkui1Dn8J9l+g6zV8731w==",
"license": "BlueOak-1.0.0",
"optional": true,
"dependencies": {
"@isaacs/fs-minipass": "^4.0.0",
"chownr": "^3.0.0",
@@ -1660,7 +1563,6 @@
"resolved": "https://registry.npmjs.org/universalify/-/universalify-2.0.1.tgz",
"integrity": "sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">= 10.0.0"
}
@@ -1669,15 +1571,13 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/url-join/-/url-join-4.0.1.tgz",
"integrity": "sha512-jk1+QP6ZJqyOiuEI9AEWQfju/nB2Pw466kbA0LEZljHwKeMgd9WrAEgEGxjPDD2+TNbbb37rTyhEfrCXfuKXnA==",
"license": "MIT",
"optional": true
"license": "MIT"
},
"node_modules/validate-npm-package-name": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-7.0.2.tgz",
"integrity": "sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==",
"license": "ISC",
"optional": true,
"engines": {
"node": "^20.17.0 || >=22.9.0"
}
@@ -1687,7 +1587,6 @@
"resolved": "https://registry.npmjs.org/which/-/which-6.0.1.tgz",
"integrity": "sha512-oGLe46MIrCRqX7ytPUf66EAYvdeMIZYn3WaocqqKZAxrBpkqHfL/qvTyJ/bTk5+AqHCjXmrv3CEWgy368zhRUg==",
"license": "ISC",
"optional": true,
"dependencies": {
"isexe": "^4.0.0"
},
@@ -1703,7 +1602,6 @@
"resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz",
"integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-styles": "^4.0.0",
"string-width": "^4.1.0",
@@ -1721,7 +1619,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1731,7 +1628,6 @@
"resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz",
"integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==",
"license": "MIT",
"optional": true,
"dependencies": {
"color-convert": "^2.0.1"
},
@@ -1747,7 +1643,6 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1757,7 +1652,6 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -1772,7 +1666,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -1785,7 +1678,6 @@
"resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz",
"integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=10"
}
@@ -1795,7 +1687,6 @@
"resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz",
"integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==",
"license": "BlueOak-1.0.0",
"optional": true,
"engines": {
"node": ">=18"
}
@@ -1805,7 +1696,6 @@
"resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz",
"integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==",
"license": "MIT",
"optional": true,
"dependencies": {
"cliui": "^8.0.1",
"escalade": "^3.1.1",
@@ -1824,7 +1714,6 @@
"resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz",
"integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==",
"license": "ISC",
"optional": true,
"engines": {
"node": ">=12"
}
@@ -1834,7 +1723,6 @@
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz",
"integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1844,7 +1732,6 @@
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
"integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
}
@@ -1854,7 +1741,6 @@
"resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz",
"integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==",
"license": "MIT",
"optional": true,
"dependencies": {
"emoji-regex": "^8.0.0",
"is-fullwidth-code-point": "^3.0.0",
@@ -1869,7 +1755,6 @@
"resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz",
"integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==",
"license": "MIT",
"optional": true,
"dependencies": {
"ansi-regex": "^5.0.1"
},
@@ -1882,7 +1767,6 @@
"resolved": "https://registry.npmjs.org/yoctocolors/-/yoctocolors-2.1.2.tgz",
"integrity": "sha512-CzhO+pFNo8ajLM2d2IW/R93ipy99LWjtwblvC1RsoSUMZgyLbYFr221TnSNT7GjGdYui6P459mw9JH/g/zW2ug==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
},

View File

@@ -7,7 +7,7 @@
"url": "https://github.com/openclaw/openclaw"
},
"type": "module",
"optionalDependencies": {
"dependencies": {
"node-llama-cpp": "3.18.1"
},
"devDependencies": {

View File

@@ -59,84 +59,6 @@ describe("mattermost monitor gating", () => {
});
});
it("processes engaged thread follow-ups without a mention", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
threadAlreadyEngaged: true,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: true,
dropReason: null,
});
});
it("engaged threads respond even when onchar is enabled but not triggered", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
threadAlreadyEngaged: true,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: true,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: true,
dropReason: null,
});
});
it("drops non-mentioned channel traffic outside an engaged thread", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
threadAlreadyEngaged: false,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: false,
dropReason: "missing-mention",
});
});
it("bypasses mention for authorized control commands and allows direct chats", () => {
const resolveRequireMention = vi.fn(() => true);

View File

@@ -43,9 +43,6 @@ export type MattermostMentionGateInput = {
requireMentionOverride?: boolean;
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
wasMentioned: boolean;
// Bot has already replied in this thread; treat follow-ups as addressed so the
// user need not re-mention on every turn (parity with Slack thread participation).
threadAlreadyEngaged?: boolean;
isControlCommand: boolean;
commandAuthorized: boolean;
oncharEnabled: boolean;
@@ -78,16 +75,12 @@ export function evaluateMattermostMentionGate(
!params.wasMentioned &&
params.commandAuthorized;
const effectiveWasMentioned =
params.wasMentioned ||
shouldBypassMention ||
params.oncharTriggered ||
params.threadAlreadyEngaged === true;
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
if (
params.oncharEnabled &&
!params.oncharTriggered &&
!params.wasMentioned &&
!params.isControlCommand &&
params.threadAlreadyEngaged !== true
!params.isControlCommand
) {
return {
shouldRequireMention,

View File

@@ -371,7 +371,6 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
it("suppresses reasoning-prefixed finals before preview finalization", async () => {
const draftStream = createDraftStreamMock();
const deliverFinal = vi.fn(async () => {});
const recordThreadParticipation = vi.fn();
await deliverMattermostReplyWithDraftPreview({
payload: { text: " \n > Reasoning:\n> _hidden_" } as never,
@@ -383,7 +382,6 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
resolvePreviewFinalText: (text) => text?.trim(),
previewState: { finalizedViaPreviewPost: false },
logVerboseMessage: vi.fn(),
recordThreadParticipation,
deliverPayload: deliverFinal,
});
@@ -392,36 +390,6 @@ describe("deliverMattermostReplyWithDraftPreview", () => {
expect(draftStream.discardPending).not.toHaveBeenCalled();
expect(draftStream.clear).not.toHaveBeenCalled();
expect(updateMattermostPostSpy).not.toHaveBeenCalled();
// No visible reply was sent, so the thread must not be marked as participated.
expect(recordThreadParticipation).not.toHaveBeenCalled();
});
it("records thread participation when a same-thread final finalizes the preview in place", async () => {
const draftStream = createDraftStreamMock();
const deliverFinal = vi.fn(async () => {});
const recordThreadParticipation = vi.fn();
await deliverMattermostReplyWithDraftPreview({
payload: { text: "All good" } as never,
info: { kind: "final" },
kind: "channel",
client: createMattermostClientMock(),
draftStream,
effectiveReplyToId: "thread-root-1",
resolvePreviewFinalText: (text) => text?.trim(),
previewState: { finalizedViaPreviewPost: false },
logVerboseMessage: vi.fn(),
recordThreadParticipation,
deliverPayload: deliverFinal,
});
// Default streaming finalizes by editing the preview post, bypassing deliverPayload —
// participation must still be recorded (regression: PR #95552 review P1).
expect(updateMattermostPostSpy).toHaveBeenCalledWith(expect.anything(), "preview-post-1", {
message: "All good",
});
expect(deliverFinal).not.toHaveBeenCalled();
expect(recordThreadParticipation).toHaveBeenCalledTimes(1);
});
it("deletes the preview after a successful normal final send", async () => {

View File

@@ -116,10 +116,6 @@ import {
import { sendMessageMattermost } from "./send.js";
import { cleanupSlashCommands } from "./slash-commands.js";
import { deactivateSlashCommands, getSlashCommandState } from "./slash-state.js";
import {
hasMattermostThreadParticipationWithPersistence,
recordMattermostThreadParticipation,
} from "./thread-participation.js";
export {
evaluateMattermostMentionGate,
@@ -331,10 +327,6 @@ type MattermostDraftPreviewDeliverParams = {
previewState: MattermostDraftPreviewState;
logVerboseMessage: (message: string) => void;
deliverPayload: (payload: ReplyPayload) => Promise<void>;
// Visible same-thread finals can be delivered by editing the draft preview in
// place (onPreviewFinalized) without ever calling deliverPayload; this lets the
// caller record thread participation on that path too.
recordThreadParticipation?: () => void;
};
export async function deliverMattermostReplyWithDraftPreview(
@@ -382,9 +374,6 @@ export async function deliverMattermostReplyWithDraftPreview(
},
onPreviewFinalized: () => {
params.previewState.finalizedViaPreviewPost = true;
// The visible final reply landed by editing the preview post, so the normal
// deliverPayload record path is skipped; record participation explicitly here.
params.recordThreadParticipation?.();
},
buildSupplementalPayload: (payload) =>
getReplyPayloadTtsSupplement(payload) ? buildTtsSupplementMediaPayload(payload) : undefined,
@@ -1495,16 +1484,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: { triggered: false, stripped: rawText };
const oncharTriggered = oncharResult.triggered;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
// Threads the bot already replied in auto-engage: follow-ups resume without
// a re-mention even under requireMention. Keyed by the thread root id.
const threadAlreadyEngaged =
kind !== "direct" && effectiveReplyToId
? await hasMattermostThreadParticipationWithPersistence({
accountId: account.accountId,
channelId,
threadRootId: effectiveReplyToId,
})
: false;
const mentionDecision = evaluateMattermostMentionGate({
kind,
cfg,
@@ -1514,7 +1493,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
requireMentionOverride: account.requireMention,
resolveRequireMention: core.channel.groups.resolveRequireMention,
wasMentioned,
threadAlreadyEngaged,
isControlCommand,
commandAuthorized,
oncharEnabled,
@@ -1803,18 +1781,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
if (info.kind === "final") {
progressDraft.markFinalReplyStarted();
}
// A visible same-thread final arrives either via a normal send or by editing
// the draft preview in place; record participation on whichever path fires.
const markThreadParticipation = () => {
if (kind !== "direct" && effectiveReplyToId) {
recordMattermostThreadParticipation(
account.accountId,
channelId,
effectiveReplyToId,
{ agentId: route.agentId },
);
}
};
await deliverMattermostReplyWithDraftPreview({
payload: payloadEntry,
info,
@@ -1825,7 +1791,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
resolvePreviewFinalText,
previewState,
logVerboseMessage,
recordThreadParticipation: markThreadParticipation,
deliverPayload: async (payloadToDeliver) => {
const outcome = await deliverMattermostReplyPayload({
core,
@@ -1844,11 +1809,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
// Record only on a visible send so threads we merely observed
// (reasoning-only/empty/suppressed) do not auto-engage later.
if (outcome === "text" || outcome === "media") {
markThreadParticipation();
}
const deliveryLog = formatMattermostFinalDeliveryOutcomeLog({
outcome,
payload: payloadToDeliver,

View File

@@ -1,115 +0,0 @@
// Mattermost tests cover thread participation cache plugin behavior.
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setMattermostRuntime } from "../runtime.js";
import {
clearMattermostThreadParticipationCache,
hasMattermostThreadParticipationWithPersistence,
recordMattermostThreadParticipation,
} from "./thread-participation.js";
// Drain microtasks + the immediate queue so the fire-and-forget persistent write
// in recordMattermostThreadParticipation has settled before we assert on it.
const flush = (): Promise<void> =>
new Promise((resolve) => {
setImmediate(resolve);
});
function setRuntime(openKeyedStore: (options: OpenKeyedStoreOptions) => unknown): void {
setMattermostRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn() {} }) },
} as unknown as PluginRuntime);
}
function setPersistentRuntime(): void {
setRuntime((options) => createPluginStateKeyedStoreForTests("mattermost", options));
}
describe("mattermost thread participation", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
clearMattermostThreadParticipationCache();
setPersistentRuntime();
});
afterEach(() => {
clearMattermostThreadParticipationCache();
resetPluginStateStoreForTests();
});
it("remembers a thread the bot replied in", async () => {
recordMattermostThreadParticipation("acct", "chan", "root-1");
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(true);
});
it("isolates participation by account, channel, and thread", async () => {
recordMattermostThreadParticipation("acct", "chan", "root-1");
await flush();
for (const probe of [
{ accountId: "other", channelId: "chan", threadRootId: "root-1" },
{ accountId: "acct", channelId: "other", threadRootId: "root-1" },
{ accountId: "acct", channelId: "chan", threadRootId: "root-2" },
]) {
await expect(hasMattermostThreadParticipationWithPersistence(probe)).resolves.toBe(false);
}
});
it("ignores empty identifiers", async () => {
recordMattermostThreadParticipation("", "chan", "root-1");
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(false);
});
it("recovers participation from the persistent store after the in-memory cache is lost", async () => {
recordMattermostThreadParticipation("acct", "chan", "root-1");
await flush();
// Simulate a restart: in-memory cache cleared, persistent SQLite store intact.
clearMattermostThreadParticipationCache();
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(true);
});
it("degrades to in-memory only when the persistent store fails", async () => {
setRuntime(() => {
throw new Error("sqlite unavailable");
});
// record + read must not throw; the in-memory cache still answers.
recordMattermostThreadParticipation("acct", "chan", "root-1");
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(true);
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "missing",
}),
).resolves.toBe(false);
});
});

View File

@@ -1,151 +0,0 @@
// Mattermost plugin module implements thread participation cache behavior.
import { resolveGlobalDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
import { getOptionalMattermostRuntime } from "../runtime.js";
/**
* In-memory + persisted cache of Mattermost threads the bot has replied in.
* Lets the bot auto-respond to thread follow-ups without a re-mention after its
* first visible reply. Mirrors the Slack `sent-thread-cache` dual-layer pattern.
*/
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const MAX_ENTRIES = 5000;
const PERSISTENT_MAX_ENTRIES = 1000;
const PERSISTENT_NAMESPACE = "mattermost.thread-participation";
type MattermostThreadParticipationRecord = {
agentId?: string;
repliedAt: number;
};
type MattermostThreadParticipationStore = {
register(
key: string,
value: MattermostThreadParticipationRecord,
opts?: { ttlMs?: number },
): Promise<void>;
lookup(key: string): Promise<MattermostThreadParticipationRecord | undefined>;
};
/**
* Keep thread participation shared across bundled chunks so thread auto-reply
* gating does not diverge between the inbound-gate and reply-dispatch paths.
*/
const MATTERMOST_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.mattermostThreadParticipation");
const threadParticipation = resolveGlobalDedupeCache(MATTERMOST_THREAD_PARTICIPATION_KEY, {
ttlMs: TTL_MS,
maxSize: MAX_ENTRIES,
});
let persistentStore: MattermostThreadParticipationStore | undefined;
let persistentStoreDisabled = false;
function makeKey(accountId: string, channelId: string, threadRootId: string): string {
return `${accountId}:${channelId}:${threadRootId}`;
}
function reportPersistentThreadParticipationError(error: unknown): void {
try {
getOptionalMattermostRuntime()
?.logging.getChildLogger({ plugin: "mattermost", feature: "thread-participation-state" })
.warn("Mattermost persistent thread participation state failed", { error: String(error) });
} catch {
// Best effort only: persistent state must never break Mattermost message handling.
}
}
function disablePersistentThreadParticipation(error: unknown): void {
persistentStoreDisabled = true;
persistentStore = undefined;
reportPersistentThreadParticipationError(error);
}
function getPersistentThreadParticipationStore(): MattermostThreadParticipationStore | undefined {
if (persistentStoreDisabled) {
return undefined;
}
if (persistentStore) {
return persistentStore;
}
const runtime = getOptionalMattermostRuntime();
if (!runtime) {
return undefined;
}
try {
persistentStore = runtime.state.openKeyedStore<MattermostThreadParticipationRecord>({
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
defaultTtlMs: TTL_MS,
});
return persistentStore;
} catch (error) {
disablePersistentThreadParticipation(error);
return undefined;
}
}
function rememberPersistentThreadParticipation(params: { key: string; agentId?: string }): void {
const store = getPersistentThreadParticipationStore();
if (!store) {
return;
}
void store
.register(params.key, {
// Stored for future per-agent thread routing; current reads only need presence.
...(params.agentId ? { agentId: params.agentId } : {}),
repliedAt: Date.now(),
})
.catch(disablePersistentThreadParticipation);
}
async function lookupPersistentThreadParticipation(key: string): Promise<boolean> {
const store = getPersistentThreadParticipationStore();
if (!store) {
return false;
}
try {
return Boolean(await store.lookup(key));
} catch (error) {
disablePersistentThreadParticipation(error);
return false;
}
}
export function recordMattermostThreadParticipation(
accountId: string,
channelId: string,
threadRootId: string,
opts?: { agentId?: string },
): void {
if (!accountId || !channelId || !threadRootId) {
return;
}
const key = makeKey(accountId, channelId, threadRootId);
threadParticipation.check(key);
rememberPersistentThreadParticipation({ key, agentId: opts?.agentId });
}
export async function hasMattermostThreadParticipationWithPersistence(params: {
accountId: string;
channelId: string;
threadRootId: string;
}): Promise<boolean> {
if (!params.accountId || !params.channelId || !params.threadRootId) {
return false;
}
const key = makeKey(params.accountId, params.channelId, params.threadRootId);
if (threadParticipation.peek(key)) {
return true;
}
const found = await lookupPersistentThreadParticipation(key);
if (found) {
threadParticipation.check(key);
}
return found;
}
export function clearMattermostThreadParticipationCache(): void {
threadParticipation.clear();
persistentStore = undefined;
persistentStoreDisabled = false;
}

View File

@@ -2,12 +2,9 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
const {
setRuntime: setMattermostRuntime,
getRuntime: getMattermostRuntime,
tryGetRuntime: getOptionalMattermostRuntime,
} = createPluginRuntimeStore<PluginRuntime>({
pluginId: "mattermost",
errorMessage: "Mattermost runtime not initialized",
});
export { getMattermostRuntime, getOptionalMattermostRuntime, setMattermostRuntime };
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "mattermost",
errorMessage: "Mattermost runtime not initialized",
});
export { getMattermostRuntime, setMattermostRuntime };

View File

@@ -559,85 +559,6 @@ describe("compileMemoryWikiVault", () => {
);
});
it("excludes concept and synthesis pages from stale-pages report", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),
initialize: true,
});
await fs.writeFile(
path.join(rootDir, "entities", "entity-alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "entity",
id: "entity.alpha",
title: "Alpha Entity",
sourceIds: ["source.alpha"],
updatedAt: "2025-06-01T00:00:00.000Z",
},
body: "# Alpha Entity\n",
}),
"utf8",
);
await fs.writeFile(
path.join(rootDir, "sources", "source-alpha.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "source",
id: "source.alpha",
title: "Alpha Source",
updatedAt: "2025-06-01T00:00:00.000Z",
},
body: "# Alpha Source\n",
}),
"utf8",
);
// Concept page with old updatedAt — should be excluded from stale-pages
await fs.writeFile(
path.join(rootDir, "concepts", "concept-beta.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "concept",
id: "concept.beta",
title: "Beta Concept",
sourceIds: ["source.alpha"],
updatedAt: "2025-06-01T00:00:00.000Z",
},
body: "# Beta Concept\n",
}),
"utf8",
);
// Synthesis page with old updatedAt — should be excluded from stale-pages
await fs.writeFile(
path.join(rootDir, "syntheses", "synthesis-gamma.md"),
renderWikiMarkdown({
frontmatter: {
pageType: "synthesis",
id: "synthesis.gamma",
title: "Gamma Synthesis",
sourceIds: ["source.alpha"],
updatedAt: "2025-06-01T00:00:00.000Z",
},
body: "# Gamma Synthesis\n",
}),
"utf8",
);
await compileMemoryWikiVault(config);
const stalePages = await fs.readFile(path.join(rootDir, "reports", "stale-pages.md"), "utf8");
// Entity and source pages still appear in stale-pages
expect(stalePages).toContain("[Alpha Entity](../entities/entity-alpha.md)");
expect(stalePages).toContain("[Alpha Source](../sources/source-alpha.md)");
// Concept and synthesis pages are excluded
expect(stalePages).not.toContain("[Beta Concept](../concepts/concept-beta.md)");
expect(stalePages).not.toContain("[Gamma Synthesis](../syntheses/synthesis-gamma.md)");
});
it("skips dashboard report pages when createDashboards is disabled", async () => {
const { rootDir, config } = await createVault({
rootDir: nextCaseRoot(),

View File

@@ -214,9 +214,6 @@ const DASHBOARD_PAGES: DashboardPageDefinition[] = [
.filter(
(page) =>
page.kind !== "report" &&
// concept/synthesis are intentionally durable references
page.kind !== "concept" &&
page.kind !== "synthesis" &&
!(
isUnmanagedRawSourceSummary(page) &&
!managedImportedSourcePagePaths.has(page.relativePath)

View File

@@ -16,7 +16,7 @@
"@openclaw/plugin-sdk": "workspace:*",
"@openclaw/slack": "workspace:*",
"@openclaw/whatsapp": "workspace:*",
"@openclaw/crabline": "0.1.0",
"crabline": "github:openclaw/crabline#b3513f66053788c6a7bd2bc76fbfc7201f647d29",
"openclaw": "2026.5.28"
},
"peerDependencies": {

View File

@@ -1,6 +0,0 @@
// Qa Lab plugin helper creates collision-resistant artifact run identifiers.
import { randomUUID } from "node:crypto";
export function createQaArtifactRunId(): string {
return `${Date.now().toString(36)}-${randomUUID().slice(0, 8)}`;
}

View File

@@ -255,39 +255,6 @@ describe("runQaCharacterEval", () => {
expect(report).not.toContain("Judge Raw Reply");
});
it("creates a unique default output directory under repo artifacts", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({
outputDir: params.outputDir,
model: params.primaryModel,
transcript: "USER Alice: hi\n\nASSISTANT openclaw: default dir reply",
}),
);
const runJudge = makeRunJudge([
{
model: "openai/gpt-5.5",
rank: 1,
score: 8,
summary: "solid",
strengths: ["clear"],
weaknesses: [],
},
]);
const result = await runQaCharacterEval({
repoRoot: tempRoot,
models: ["openai/gpt-5.5"],
runSuite,
runJudge,
});
expect(path.dirname(result.outputDir)).toBe(path.join(tempRoot, ".artifacts", "qa-e2e"));
expect(path.basename(result.outputDir)).toMatch(
/^character-eval-[a-z0-9]+-[a-f0-9]{8}$/u,
);
await expect(fs.stat(result.reportPath).then((stats) => stats.isFile())).resolves.toBe(true);
});
it("can hide candidate model refs from judge prompts and map rankings back", async () => {
const runSuite = vi.fn(async (params: CharacterRunSuiteParams) =>
makeSuiteResult({

View File

@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import path from "node:path";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import { isQaFastModeModelRef, type QaProviderMode } from "./model-selection.js";
import {
QA_FRONTIER_CHARACTER_EVAL_MODELS,
@@ -521,7 +520,7 @@ export async function runQaCharacterEval(params: QaCharacterEvalParams) {
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `character-eval-${createQaArtifactRunId()}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `character-eval-${Date.now().toString(36)}`);
const runsDir = path.join(outputDir, "runs");
await fs.mkdir(runsDir, { recursive: true });

View File

@@ -4,7 +4,7 @@ import path from "node:path";
import {
OPENCLAW_CRABLINE_DEFAULT_CHANNEL,
resolveOpenClawCrablineChannelDriverSelection,
} from "@openclaw/crabline";
} from "crabline";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
@@ -17,7 +17,6 @@ import {
type QaRuntimeParitySuiteSummary,
} from "./agentic-parity-report.js";
import { resolveQaParityPackScenarioIds } from "./agentic-parity.js";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import { runQaCharacterEval, type QaCharacterModelOptions } from "./character-eval.js";
import { resolveRepoRelativeOutputDir } from "./cli-paths.js";
import {
@@ -408,7 +407,7 @@ async function runQaParityPreflight(params: {
".artifacts",
"qa-e2e",
"preflight",
`suite-${createQaArtifactRunId()}`,
`suite-${Date.now().toString(36)}`,
);
const result = await runQaSuiteWithInfraRetry(() =>
runQaFlowSuiteFromRuntime({
@@ -1057,7 +1056,7 @@ export async function runQaParityReportCommand(opts: {
}
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `parity-${createQaArtifactRunId()}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `parity-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
if (opts.runtimeAxis === true) {
@@ -1150,7 +1149,7 @@ export async function runQaConfidenceReportCommand(opts: {
const artifactRoot = path.resolve(repoRoot, opts.artifactRoot ?? ".");
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `confidence-${createQaArtifactRunId()}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `confidence-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
const manifest = await readQaConfidenceManifestFile(manifestPath);
const reportPayload = await buildQaConfidenceReport({
@@ -1179,7 +1178,7 @@ export async function runQaConfidenceSelfTestCommand(opts: {
const repoRoot = path.resolve(opts.repoRoot ?? process.cwd());
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `confidence-self-test-${createQaArtifactRunId()}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `confidence-self-test-${Date.now().toString(36)}`);
const result = await writeQaConfidenceSelfTestArtifacts({ outputDir });
process.stdout.write(`QA confidence self-test report: ${result.reportPath}\n`);
process.stdout.write(`QA confidence self-test summary: ${result.summaryPath}\n`);
@@ -1269,7 +1268,7 @@ export async function runQaJsonlReplayCommand(opts: {
const transcriptDir = path.resolve(repoRoot, opts.transcripts ?? "qa/scenarios/jsonl-replay");
const outputDir =
resolveRepoRelativeOutputDir(repoRoot, opts.outputDir) ??
path.join(repoRoot, ".artifacts", "qa-e2e", `jsonl-replay-${createQaArtifactRunId()}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `jsonl-replay-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
const result = await runJsonlReplay(
{

View File

@@ -800,33 +800,6 @@ describe("qa cli registration", () => {
await expect(invalidProgram.parseAsync(["node", "openclaw", ...args])).rejects.toThrow(message);
});
it.each([
[["qa", "ui", "--port", "65536"], "--port must be a TCP port between 1 and 65535."],
[
["qa", "ui", "--advertise-port", "999999"],
"--advertise-port must be a TCP port between 1 and 65535.",
],
[
["qa", "docker-scaffold", "--output-dir", "/tmp/qa", "--gateway-port", "65536"],
"--gateway-port must be a TCP port between 1 and 65535.",
],
[
["qa", "up", "--qa-lab-port", "65536"],
"--qa-lab-port must be a TCP port between 1 and 65535.",
],
[["qa", "aimock", "--port", "65536"], "--port must be a TCP port between 1 and 65535."],
])("rejects out-of-range QA port option %j", async (args, message) => {
const invalidProgram = new Command();
invalidProgram.exitOverride();
invalidProgram.configureOutput({
writeErr: () => {},
writeOut: () => {},
});
registerQaLabCli(invalidProgram);
await expect(invalidProgram.parseAsync(["node", "openclaw", ...args])).rejects.toThrow(message);
});
it("shows an enable hint when a discovered runner plugin is installed but blocked", async () => {
listQaRunnerCliContributions.mockReset().mockReturnValue([createBlockedQaRunnerContribution()]);
const blockedProgram = new Command();

View File

@@ -62,7 +62,6 @@ const QA_RUN_PROFILE_ONLY_OPTIONS = [
] as const;
const QA_RUN_SELF_CHECK_ONLY_OPTIONS = [{ optionName: "output", flag: "--output" }] as const;
const MAX_QA_CLI_TCP_PORT = 65_535;
type QaSuiteCliOptions = QaScenarioRunCliOptions & {
channelDriver?: QaSuiteCommandOptions["channelDriver"];
@@ -106,14 +105,6 @@ function parseQaCliPositiveIntegerOption(value: string, flag: string): number {
return parsed;
}
function parseQaCliTcpPortOption(value: string, flag: string): number {
const parsed = parseQaCliPositiveIntegerOption(value, flag);
if (parsed > MAX_QA_CLI_TCP_PORT) {
throw invalidQaCliArgument(`${flag} must be a TCP port between 1 and 65535.`);
}
return parsed;
}
function parseQaEvidenceModeOption(value: string): QaProfileCommandOptions["evidenceMode"] {
const evidenceMode = value.trim();
if (evidenceMode === "full" || evidenceMode === "slim") {
@@ -876,11 +867,11 @@ export function registerQaLabCli(program: Command) {
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
.option("--host <host>", "Bind host", "127.0.0.1")
.option("--port <port>", "Bind port", (value: string) =>
parseQaCliTcpPortOption(value, "--port"),
parseQaCliPositiveIntegerOption(value, "--port"),
)
.option("--advertise-host <host>", "Optional public host to advertise in bootstrap payloads")
.option("--advertise-port <port>", "Optional public port to advertise", (value: string) =>
parseQaCliTcpPortOption(value, "--advertise-port"),
parseQaCliPositiveIntegerOption(value, "--advertise-port"),
)
.option("--control-ui-url <url>", "Optional Control UI URL to embed beside the QA panel")
.option(
@@ -918,10 +909,10 @@ export function registerQaLabCli(program: Command) {
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
.requiredOption("--output-dir <path>", "Output directory for docker-compose + state files")
.option("--gateway-port <port>", "Gateway host port", (value: string) =>
parseQaCliTcpPortOption(value, "--gateway-port"),
parseQaCliPositiveIntegerOption(value, "--gateway-port"),
)
.option("--qa-lab-port <port>", "QA lab host port", (value: string) =>
parseQaCliTcpPortOption(value, "--qa-lab-port"),
parseQaCliPositiveIntegerOption(value, "--qa-lab-port"),
)
.option("--provider-base-url <url>", "Provider base URL for the QA gateway")
.option("--image <name>", "Prebaked image name", "openclaw:qa-local-prebaked")
@@ -959,10 +950,10 @@ export function registerQaLabCli(program: Command) {
.option("--repo-root <path>", "Repository root to target when running from a neutral cwd")
.option("--output-dir <path>", "Output directory for docker-compose + state files")
.option("--gateway-port <port>", "Gateway host port", (value: string) =>
parseQaCliTcpPortOption(value, "--gateway-port"),
parseQaCliPositiveIntegerOption(value, "--gateway-port"),
)
.option("--qa-lab-port <port>", "QA lab host port", (value: string) =>
parseQaCliTcpPortOption(value, "--qa-lab-port"),
parseQaCliPositiveIntegerOption(value, "--qa-lab-port"),
)
.option("--provider-base-url <url>", "Provider base URL for the QA gateway")
.option("--image <name>", "Image tag", "openclaw:qa-local-prebaked")
@@ -994,7 +985,7 @@ export function registerQaLabCli(program: Command) {
.description(providerCommand.description)
.option("--host <host>", "Bind host", "127.0.0.1")
.option("--port <port>", "Bind port", (value: string) =>
parseQaCliTcpPortOption(value, "--port"),
parseQaCliPositiveIntegerOption(value, "--port"),
)
.action(async (opts: { host?: string; port?: number }) => {
await runQaProviderServer(providerCommand.providerMode, opts);

View File

@@ -1,7 +1,7 @@
// Qa Lab tests cover Crabline fake-provider transport integration behavior.
import fs from "node:fs/promises";
import path from "node:path";
import { OPENCLAW_CRABLINE_MANIFEST_PATH } from "@openclaw/crabline";
import { OPENCLAW_CRABLINE_MANIFEST_PATH } from "crabline";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { withTempDir } from "openclaw/plugin-sdk/test-env";
import { describe, expect, it } from "vitest";

View File

@@ -7,7 +7,7 @@ import {
startOpenClawCrablineAdapter,
type OpenClawCrablineChannelDriverSelection,
type StartedOpenClawCrablineAdapter,
} from "@openclaw/crabline";
} from "crabline";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -101,10 +101,7 @@ async function postCrablineInbound(params: {
url: params.adapter.manifest.endpoints.adminInboundUrl,
init: {
body: JSON.stringify(params.providerBody),
headers: {
authorization: `Bearer ${params.adapter.manifest.adminToken}`,
"content-type": "application/json",
},
headers: { "content-type": "application/json" },
method: "POST",
},
policy: { allowPrivateNetwork: true },

View File

@@ -1231,9 +1231,6 @@ describe("buildQaRuntimeEnv", () => {
await expect(readFile(path.join(artifactDir, "README.txt"), "utf8")).resolves.toContain(
"was not copied because it may contain credentials or auth tokens",
);
await expect(readFile(path.join(artifactDir, "README.txt"), "utf8")).resolves.not.toContain(
tempRoot,
);
});
it("rejects preserved gateway artifacts outside the repo root", async () => {

View File

@@ -162,7 +162,7 @@ async function preserveQaGatewayDebugArtifacts(params: {
[
"Only sanitized gateway debug artifacts are preserved here.",
"The full QA gateway runtime was not copied because it may contain credentials or auth tokens.",
"Original runtime temp root omitted because local temp paths can identify the runner.",
`Original runtime temp root: ${params.tempRoot}`,
"",
].join("\n"),
"utf8",

View File

@@ -657,8 +657,7 @@ describe("qa-lab server", () => {
});
const result = await lab.runSelfCheck();
expect(path.dirname(result.outputPath)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
expect(path.basename(result.outputPath)).toMatch(/^self-check-[a-z0-9]+-[a-f0-9]{8}\.md$/u);
expect(result.outputPath).toBe(path.join(repoRoot, ".artifacts", "qa-e2e", "self-check.md"));
expect(await readFile(result.outputPath, "utf8")).toContain("Synthetic Slack-class roundtrip");
});

View File

@@ -15,7 +15,6 @@ import { writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtim
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { chromium } from "playwright-core";
import { z } from "zod";
import { createQaArtifactRunId } from "../../artifact-run-id.js";
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
import { startQaGatewayChild } from "../../gateway-child.js";
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
@@ -1528,7 +1527,7 @@ export async function runDiscordQaLive(params: {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `discord-${createQaArtifactRunId()}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `discord-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
const providerMode = normalizeQaProviderMode(

View File

@@ -9,7 +9,6 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import { createQaArtifactRunId } from "../../artifact-run-id.js";
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
import { startQaGatewayChild } from "../../gateway-child.js";
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
@@ -1712,7 +1711,7 @@ export async function runSlackQaLive(params: {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `slack-${createQaArtifactRunId()}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `slack-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
const providerMode = normalizeQaProviderMode(

View File

@@ -11,7 +11,6 @@ import {
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { isRecord, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
import { createQaArtifactRunId } from "../../artifact-run-id.js";
import {
QA_EVIDENCE_FILENAME,
buildLiveTransportEvidenceSummary,
@@ -1806,7 +1805,7 @@ export async function runTelegramQaLive(params: {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `telegram-${createQaArtifactRunId()}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `telegram-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
const providerMode = normalizeQaProviderMode(

View File

@@ -15,7 +15,6 @@ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { normalizeStringEntries, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { z } from "zod";
import { createQaArtifactRunId } from "../../artifact-run-id.js";
import { QA_EVIDENCE_FILENAME, buildLiveTransportEvidenceSummary } from "../../evidence-summary.js";
import { startQaGatewayChild } from "../../gateway-child.js";
import { isTruthyOptIn } from "../../mantis-options.runtime.js";
@@ -3034,7 +3033,7 @@ export async function runWhatsAppQaLive(params: {
const repoRoot = path.resolve(params.repoRoot ?? process.cwd());
const outputDir =
params.outputDir ??
path.join(repoRoot, ".artifacts", "qa-e2e", `whatsapp-${createQaArtifactRunId()}`);
path.join(repoRoot, ".artifacts", "qa-e2e", `whatsapp-${Date.now().toString(36)}`);
await fs.mkdir(outputDir, { recursive: true });
const providerMode = normalizeQaProviderMode(

View File

@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import fs from "node:fs";
import { access, mkdir, writeFile } from "node:fs/promises";
import path from "node:path";
import type { OpenClawCrablineChannelDriverSelection } from "@openclaw/crabline";
import type { OpenClawCrablineChannelDriverSelection } from "crabline";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import { appendRegularFile } from "openclaw/plugin-sdk/security-runtime";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";

View File

@@ -3314,7 +3314,7 @@ describe("qa mock openai server", () => {
const toolPlanOutput = outputItem(await response.json());
expect(toolPlanOutput.type).toBe("function_call");
expect(toolPlanOutput.name).toBe("web_search");
expect(String(toolPlanOutput.arguments)).toContain("OPENCLAW_QA_WEB_SEARCH_DENIED_INPUT");
expect(String(toolPlanOutput.arguments)).toContain("denied-input");
});
it("plans QA subagent handoff calls even when Codex dynamic tools are not in body.tools", async () => {

View File

@@ -5,7 +5,6 @@ import { setTimeout as sleep } from "node:timers/promises";
import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
import { readRequestBodyWithLimit } from "openclaw/plugin-sdk/webhook-ingress";
import { closeQaHttpServer } from "../../bus-server.js";
import { QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY } from "../../qa-web-search-provider.js";
import { writeJson } from "../shared/http-json.js";
type ResponsesInputItem = Record<string, unknown>;
@@ -861,9 +860,6 @@ function extractToolSearchTarget(text: string): string | null {
}
function buildQaToolSearchArgs(targetTool: string, failureMode: boolean): Record<string, unknown> {
if (failureMode && targetTool === "web_search") {
return { query: QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY };
}
if (failureMode) {
return { __qaFailureMode: "denied-input" };
}
@@ -1539,57 +1535,49 @@ function buildToolCallEvents(prompt: string): StreamEvent[] {
function buildReleaseAuditJson() {
return `${JSON.stringify(
{
verified: false,
verified: true,
findings: [
{
id: "REL-GATEWAY-417",
source: "src/gateway/reconnect.ts",
status: "retry jitter verified, resume token fallback still needs manual spot check",
verified: true,
},
{
id: "REL-CHANNEL-238",
source: "src/channels/delivery.ts",
status: "thread replies preserve ordering, root-channel fallback needs handoff note",
verified: true,
},
{
id: "REL-CRON-904",
source: "src/scheduling/cron.ts",
status: "single-run lock verified for restart wakeups",
verified: true,
},
{
id: "REL-MEMORY-552",
source: "src/memory/recall.ts",
status:
"fallback summary survives empty memory search; ranking sample needs second reviewer",
verified: true,
},
{
id: "REL-PLUGIN-319",
source: "src/plugins/runtime.ts",
status: "bundled runtime manifest loads cleanly after restart",
verified: true,
},
{
id: "REL-INSTALL-846",
source: "install/update.ts",
status: "update smoke passed from previous stable tag",
verified: true,
},
{
id: "REL-DOCS-611",
source: "docs/operator-notes.md",
status:
"docs mention reconnect, cron, memory, plugin, and installer checks; channel ordering and UI notes need maintainer handoff",
verified: true,
},
{
id: "REL-UI-BLOCKED",
source: "ui/control-panel.ts",
status: "blocked: source file was referenced by checklist but missing from the fixture",
verified: false,
},
],
},

View File

@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import { createQaLabWebSearchProvider as createQaLabWebSearchContractProvider } from "../web-search-contract-api.js";
import {
createQaLabWebSearchProvider,
QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY,
QA_LAB_WEB_SEARCH_PROVIDER_ID,
} from "./qa-web-search-provider.js";
@@ -56,16 +55,4 @@ describe("qa-lab web search provider", () => {
await expect(tool.execute({ __qaFailureMode: "denied-input" })).rejects.toThrow(/query/i);
});
it("keeps the QA failure sentinel as a deterministic tool failure", async () => {
const provider = createQaLabWebSearchProvider();
const tool = provider.createTool({});
if (!tool) {
throw new Error("expected QA Lab web search tool");
}
await expect(tool.execute({ query: QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY })).rejects.toThrow(
/denied input sentinel/i,
);
});
});

View File

@@ -9,7 +9,6 @@ import {
} from "openclaw/plugin-sdk/provider-web-search";
export const QA_LAB_WEB_SEARCH_PROVIDER_ID = "qa-lab-search";
export const QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY = "OPENCLAW_QA_WEB_SEARCH_DENIED_INPUT";
const QaLabWebSearchSchema = {
type: "object",
@@ -65,9 +64,6 @@ export function createQaLabWebSearchProvider(): WebSearchProviderPlugin {
parameters: QaLabWebSearchSchema,
execute: async (args) => {
const query = readStringParam(args, "query", { required: true });
if (query === QA_LAB_WEB_SEARCH_DENIED_INPUT_QUERY) {
throw new Error("QA Lab web_search denied input sentinel");
}
const count =
readPositiveIntegerParam(args, "count", {
max: MAX_SEARCH_COUNT,

View File

@@ -161,22 +161,6 @@ describe("qa run config", () => {
expect(outputDir.startsWith(path.join(repoRoot, ".artifacts", "qa-e2e", "lab-"))).toBe(true);
});
it("keeps generated run output dirs unique within the same millisecond", () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-06-23T07:30:00.000Z"));
const repoRoot = path.resolve("/tmp/openclaw-repo");
const first = createQaRunOutputDir(repoRoot);
const second = createQaRunOutputDir(repoRoot);
expect(first).not.toBe(second);
expect(path.basename(first)).toMatch(/^lab-2026-06-23-073000000Z-[0-9a-f]{8}$/u);
expect(path.basename(second)).toMatch(/^lab-2026-06-23-073000000Z-[0-9a-f]{8}$/u);
} finally {
vi.useRealTimers();
}
});
it("prefers the Codex OAuth default when the runtime resolver says it is available", () => {
defaultQaRuntimeModelForMode.mockImplementation((mode, options) =>
mode === "live-frontier"

View File

@@ -1,5 +1,4 @@
// Qa Lab helper module supports run config behavior.
import { randomUUID } from "node:crypto";
import path from "node:path";
import { uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
import { defaultQaModelForMode as defaultStaticQaModelForMode } from "./model-selection.js";
@@ -133,5 +132,5 @@ export function createIdleQaRunnerSnapshot(scenarios: QaSeedScenario[]): QaLabRu
export function createQaRunOutputDir(baseDir = process.cwd()) {
const stamp = new Date().toISOString().replaceAll(":", "").replaceAll(".", "").replace("T", "-");
return path.join(baseDir, ".artifacts", "qa-e2e", `lab-${stamp}-${randomUUID().slice(0, 8)}`);
return path.join(baseDir, ".artifacts", "qa-e2e", `lab-${stamp}`);
}

View File

@@ -611,7 +611,7 @@ describe("qa scenario catalog", () => {
"this seeded scenario is mock-openai only",
);
expect(heartbeatFlow).toContain("sessionKey");
expect(heartbeatFlow).toContain("commitmentOutbound.length === 0");
expect(heartbeatFlow).toContain("targetOutbound.length === 0");
expect(heartbeatFlow).not.toContain("waitForNoOutbound");
});

View File

@@ -10,7 +10,7 @@ export const QA_MATURITY_TAXONOMY_PATH = "taxonomy.yaml";
export const QA_MATURITY_SCORES_PATH = "qa/maturity-scores.yaml";
export const QA_MATURITY_SCORE_KEYS = ["quality", "completeness"] as const;
export const QA_MATURITY_SCORE_LABELS = [
"Clawesome",
"Lovable",
"Stable",
"Beta",
"Alpha",

View File

@@ -53,13 +53,10 @@ describe("resolveQaSelfCheckOutputPath", () => {
).toBe("/tmp/custom/self-check.md");
});
it("anchors default self-check reports under unique files in the provided repo root", () => {
it("anchors default self-check reports under the provided repo root", () => {
const repoRoot = path.resolve("/tmp/openclaw-repo");
const firstPath = resolveQaSelfCheckOutputPath({ repoRoot });
const secondPath = resolveQaSelfCheckOutputPath({ repoRoot });
expect(path.dirname(firstPath)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
expect(path.basename(firstPath)).toMatch(/^self-check-[a-z0-9]+-[a-f0-9]{8}\.md$/u);
expect(secondPath).not.toBe(firstPath);
expect(resolveQaSelfCheckOutputPath({ repoRoot })).toBe(
path.join(repoRoot, ".artifacts", "qa-e2e", "self-check.md"),
);
});
});

View File

@@ -3,7 +3,6 @@ import fs from "node:fs/promises";
import path from "node:path";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { renderQaMarkdownReport } from "openclaw/plugin-sdk/qa-runtime";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import type { QaBusState } from "./bus-state.js";
import { createQaTransportAdapter, type QaTransportId } from "./qa-transport-registry.js";
import { runQaScenario, type QaScenarioResult } from "./scenario.js";
@@ -28,7 +27,7 @@ export function resolveQaSelfCheckOutputPath(params?: { outputPath?: string; rep
return params.outputPath;
}
const repoRoot = path.resolve(params?.repoRoot ?? process.cwd());
return path.join(repoRoot, ".artifacts", "qa-e2e", `self-check-${createQaArtifactRunId()}.md`);
return path.join(repoRoot, ".artifacts", "qa-e2e", "self-check.md");
}
export async function runQaSelfCheckAgainstState(params: {

View File

@@ -28,19 +28,23 @@ async function makeTempRepo(prefix: string) {
return repoRoot;
}
async function writeEvidence(pathLocal: string, writeFile = true) {
const evidence = {
kind: "openclaw.qa.evidence-summary",
schemaVersion: 2,
generatedAt: "2026-06-14T00:00:00.000Z",
evidenceMode: "full",
entries: [],
};
if (writeFile) {
await fs.mkdir(path.dirname(pathLocal), { recursive: true });
await fs.writeFile(pathLocal, `${JSON.stringify(evidence, null, 2)}\n`, "utf8");
}
return evidence;
async function writeEvidence(pathLocal: string) {
await fs.mkdir(path.dirname(pathLocal), { recursive: true });
await fs.writeFile(
pathLocal,
`${JSON.stringify(
{
kind: "openclaw.qa.evidence-summary",
schemaVersion: 2,
generatedAt: "2026-06-14T00:00:00.000Z",
evidenceMode: "full",
entries: [],
},
null,
2,
)}\n`,
"utf8",
);
}
describe("qa suite runtime launcher", () => {
@@ -48,17 +52,12 @@ describe("qa suite runtime launcher", () => {
runQaFlowSuite.mockReset();
runQaTestFileScenarios.mockReset();
runQaFlowSuite.mockImplementation(
async (
params:
| { outputDir?: string; scenarioIds?: string[]; writeEvidenceFile?: boolean }
| undefined,
) => {
async (params: { outputDir?: string; scenarioIds?: string[] } | undefined) => {
const outputDir = params?.outputDir ?? "/tmp/qa-flow";
const evidencePath = path.join(outputDir, "qa-evidence.json");
const evidence = await writeEvidence(evidencePath, params?.writeEvidenceFile);
await writeEvidence(evidencePath);
const scenarioIds = params?.scenarioIds ?? ["channel-chat-baseline"];
return {
evidence,
outputDir,
evidencePath,
reportPath: path.join(outputDir, "qa-suite-report.md"),
@@ -77,16 +76,14 @@ describe("qa suite runtime launcher", () => {
async (params: {
outputDir: string;
scenarios: Array<{ id: string; execution: { kind: "script" | "vitest" | "playwright" } }>;
writeEvidenceFile?: boolean;
}) => {
const [scenario] = params.scenarios;
if (!scenario) {
throw new Error("expected scenario");
}
const evidencePath = path.join(params.outputDir, "qa-evidence.json");
const evidence = await writeEvidence(evidencePath, params.writeEvidenceFile);
await writeEvidence(evidencePath);
return {
evidence,
outputDir: params.outputDir,
executionKind: scenario.execution.kind,
evidencePath,
@@ -250,27 +247,15 @@ describe("qa suite runtime launcher", () => {
expect.objectContaining({
outputDir: path.join(outputDir, "flow"),
scenarioIds: ["channel-chat-baseline"],
writeEvidenceFile: false,
}),
);
expect(runQaTestFileScenarios).toHaveBeenCalledWith(
expect.objectContaining({
outputDir: path.join(outputDir, "playwright"),
writeEvidenceFile: false,
}),
);
await expect(fs.access(path.join(outputDir, "qa-suite-summary.json"))).resolves.toBeUndefined();
await expect(fs.access(path.join(outputDir, "qa-evidence.json"))).resolves.toBeUndefined();
await expect(fs.access(path.join(outputDir, "flow", "qa-evidence.json"))).rejects.toMatchObject(
{
code: "ENOENT",
},
);
await expect(
fs.access(path.join(outputDir, "playwright", "qa-evidence.json")),
).rejects.toMatchObject({
code: "ENOENT",
});
const summary = JSON.parse(
await fs.readFile(path.join(outputDir, "qa-suite-summary.json"), "utf8"),
) as {

View File

@@ -175,7 +175,6 @@ async function runQaTestFileSuiteFromRuntime(params: {
providerMode,
primaryModel,
scenarios: params.scenarios,
writeEvidenceFile: runParams?.writeEvidenceFile,
});
}
@@ -293,13 +292,6 @@ async function readQaSuiteEvidenceSummary(evidencePath: string) {
return validateQaEvidenceSummaryJson(JSON.parse(await fs.readFile(evidencePath, "utf8")));
}
async function resolveQaSuiteResultEvidenceSummary(result: {
evidence?: QaEvidenceSummaryJson;
evidencePath: string;
}) {
return result.evidence ?? (await readQaSuiteEvidenceSummary(result.evidencePath));
}
function mergeQaEvidenceSummaries(params: {
evidenceSummaries: readonly QaEvidenceSummaryJson[];
generatedAt: string;
@@ -497,7 +489,6 @@ async function runUnifiedQaSuite(params: {
flowPartitions.length === 1
? suitePartitionOutputDir(outputDir, "flow")
: flowSuitePartitionOutputDir(outputDir, partition.kind),
writeEvidenceFile: false,
providerMode,
primaryModel,
alternateModel,
@@ -521,7 +512,7 @@ async function runUnifiedQaSuite(params: {
}
}
return {
evidenceSummaries: [await resolveQaSuiteResultEvidenceSummary(result)],
evidenceSummaries: [await readQaSuiteEvidenceSummary(result.evidencePath)],
scenarioResults,
};
},
@@ -539,14 +530,13 @@ async function runUnifiedQaSuite(params: {
runParams: {
...params.runParams,
outputDir: suitePartitionOutputDir(outputDir, kind),
writeEvidenceFile: false,
providerMode,
primaryModel,
scenarioIds: testFileScenarios.map((scenario) => scenario.id),
},
scenarios: testFileScenarios,
});
testFileEvidenceSummaries.push(await resolveQaSuiteResultEvidenceSummary(result));
testFileEvidenceSummaries.push(await readQaSuiteEvidenceSummary(result.evidencePath));
testFileScenarioResults.push(
...result.results.map((scenarioResult) => ({
scenarioId: scenarioResult.scenario.id,

View File

@@ -86,22 +86,6 @@ describe("qa suite planning helpers", () => {
}
});
it("creates unique default suite output dirs inside the repo root", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-suite-default-root-"));
try {
const firstDir = await resolveQaSuiteOutputDir(repoRoot);
const secondDir = await resolveQaSuiteOutputDir(repoRoot);
expect(path.dirname(firstDir)).toBe(path.join(repoRoot, ".artifacts", "qa-e2e"));
expect(path.basename(firstDir)).toMatch(/^suite-[a-z0-9]+-[a-f0-9]{8}$/u);
expect(secondDir).not.toBe(firstDir);
await expect(lstat(firstDir).then((stats) => stats.isDirectory())).resolves.toBe(true);
await expect(lstat(secondDir).then((stats) => stats.isDirectory())).resolves.toBe(true);
} finally {
await rm(repoRoot, { recursive: true, force: true });
}
});
it("rejects symlinked suite output dirs that escape the repo root", async () => {
const repoRoot = await mkdtemp(path.join(os.tmpdir(), "qa-suite-root-"));
const outsideRoot = await mkdtemp(path.join(os.tmpdir(), "qa-suite-outside-"));

View File

@@ -2,7 +2,6 @@
import path from "node:path";
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { createQaArtifactRunId } from "./artifact-run-id.js";
import { ensureRepoBoundDirectory, resolveRepoRelativeOutputDir } from "./cli-paths.js";
import type { QaCliBackendAuthMode } from "./gateway-child.js";
import { splitQaModelRef as splitModelRef, type QaProviderMode } from "./model-selection.js";
@@ -339,7 +338,7 @@ async function mapQaSuiteWithConcurrency<T, U>(
async function resolveQaSuiteOutputDir(repoRoot: string, outputDir?: string) {
const targetDir = !outputDir
? path.join(repoRoot, ".artifacts", "qa-e2e", `suite-${createQaArtifactRunId()}`)
? path.join(repoRoot, ".artifacts", "qa-e2e", `suite-${Date.now().toString(36)}`)
: outputDir;
if (!path.isAbsolute(targetDir)) {
const resolved = resolveRepoRelativeOutputDir(repoRoot, targetDir);

View File

@@ -274,36 +274,6 @@ describe("qa suite", () => {
}
});
it("can return evidence without writing duplicate child evidence files", async () => {
const outputDir = await tempDirs.makeTempDir("qa-suite-artifacts-memory-evidence-");
try {
const artifacts = await qaSuiteProgressTesting.writeQaSuiteArtifacts({
outputDir,
startedAt: new Date("2026-04-11T00:00:00.000Z"),
finishedAt: new Date("2026-04-11T00:01:00.000Z"),
scenarios: [{ name: "Baseline", status: "pass", steps: [] }],
scenarioDefinitions: [makeQaSuiteTestScenario("baseline")],
transport: {
id: "qa-channel",
createReportNotes: () => [],
} as unknown as QaTransportAdapter,
providerMode: "mock-openai",
primaryModel: "mock-openai/gpt-5.5",
alternateModel: "mock-openai/gpt-5.5-alt",
fastMode: true,
concurrency: 1,
writeEvidenceFile: false,
});
expect(artifacts.evidence?.kind).toBe(QA_EVIDENCE_SUMMARY_KIND);
await expect(fs.access(artifacts.evidencePath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(fs.access(artifacts.reportPath)).resolves.toBeUndefined();
await expect(fs.access(artifacts.summaryPath)).resolves.toBeUndefined();
} finally {
await fs.rm(outputDir, { recursive: true, force: true });
}
});
it("writes Crabline channel-driver smoke artifacts when selected", async () => {
const outputDir = await tempDirs.makeTempDir("qa-suite-crabline-");
try {
@@ -463,7 +433,6 @@ describe("qa suite", () => {
enabledPluginIds: ["acpx"],
transportReadyTimeoutMs: 180_000,
forcedRuntime: "codex",
writeEvidenceFile: false,
},
}),
).toMatchObject({
@@ -476,7 +445,6 @@ describe("qa suite", () => {
enabledPluginIds: ["acpx"],
transportReadyTimeoutMs: 180_000,
forcedRuntime: "codex",
writeEvidenceFile: false,
});
});

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