diff --git a/.github/workflows/full-release-validation.yml b/.github/workflows/full-release-validation.yml index 25d7c07d63c2..5a811a13e53f 100644 --- a/.github/workflows/full-release-validation.yml +++ b/.github/workflows/full-release-validation.yml @@ -426,43 +426,111 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: ci TARGET_REF: ${{ inputs.ref }} TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} FAIL_FAST: ${{ inputs.fail_fast }} - run: | + run: &full_release_child_dispatch | set -euo pipefail + FAIL_FAST="${FAIL_FAST:-false}" + + gh_with_retry() { + local output status attempt + for attempt in 1 2 3 4 5 6; do + set +e + output="$(gh "$@" 2>&1)" + status=$? + set -e + if [[ "$status" -eq 0 ]]; then + printf '%s\n' "$output" + return 0 + fi + if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then + echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 + sleep $((attempt * 10)) + continue + fi + printf '%s\n' "$output" >&2 + return "$status" + done + printf '%s\n' "$output" >&2 + return "$status" + } + + fetch_child_run_json() { + gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" + } + + fetch_child_jobs() { + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" || "$workflow" == "openclaw-performance.yml" ]]; then + gh_with_retry run view "$run_id" --json jobs --jq '.jobs[]' + return + fi + gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' + } + + read_child_run_field() { + local field="$1" + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" || "$workflow" == "openclaw-performance.yml" ]]; then + case "$field" in + head_sha) field=headSha ;; + html_url) field=url ;; + esac + gh_with_retry run view "$run_id" --json "$field" --jq ".${field} // \"\"" + return + fi + fetch_child_run_json | jq -r ".${field} // \"\"" + } + + release_check_blocking_job() { + if [[ "$RELEASE_PROFILE" == "beta" && "$1" == "Run package acceptance / Telegram package acceptance / "* ]]; then + return 1 + fi + case "$1" in + "resolve_target" | \ + "Prepare release package artifact" | \ + "install_smoke_release_checks / "* | \ + "Run package acceptance" | \ + "Run package acceptance / "*) + return 0 + ;; + esac + return 1 + } + + release_checks_advisory_only() { + local run_json="$1" + local verifier_conclusion name saw_advisory failed + verifier_conclusion="$( + jq -r '.jobs[] | select(.name == "Verify release checks") | .conclusion' <<< "$run_json" | + tail -n 1 + )" + if [[ "$verifier_conclusion" != "success" ]]; then + return 1 + fi + saw_advisory=0 + failed=0 + while IFS= read -r name; do + [[ -z "${name// }" ]] && continue + if release_check_blocking_job "$name"; then + echo "::error::${name} is a package-safety Tideclaw alpha release-check lane." + failed=1 + else + saw_advisory=1 + fi + done < <(jq -r '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | .name' <<< "$run_json") + [[ "$saw_advisory" == "1" && "$failed" == "0" ]] + } dispatch_and_wait() { local workflow="$1" local dispatch_run_name="$2" shift 2 + local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json jobs_json child_head_sha encoded_workflow_ref current_workflow_sha - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" current_workflow_sha="$( gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha @@ -471,13 +539,13 @@ jobs: echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 return 1 fi + # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. set +e dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" dispatch_status=$? set -e printf '%s\n' "$dispatch_output" - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then echo "::error::${workflow} dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 exit "$dispatch_status" @@ -504,7 +572,6 @@ jobs: fi sleep 5 done - if [[ -z "$run_id" ]]; then echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 exit 1 @@ -512,48 +579,21 @@ jobs: if [[ "$dispatch_status" -ne 0 ]]; then echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 fi - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - - fetch_child_jobs() { - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true + if [[ -n "${active_child_run_id:-}" ]]; then + echo "Cancelling child workflow ${active_child_workflow}: ${active_child_run_id}" >&2 + gh run cancel "$active_child_run_id" >/dev/null 2>&1 || true fi } + # EXIT traps run after function locals unwind; preserve only adopted child identity. + active_child_workflow="$workflow" + active_child_run_id="$run_id" trap cancel_child EXIT INT TERM - child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" + child_head_sha="$(read_child_run_field head_sha)" if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." cancel_child @@ -561,9 +601,70 @@ jobs: exit 1 fi + fail_fast_failed_jobs() { + if [[ "$FAIL_FAST" != "true" ]]; then + return 0 + fi + local failed_jobs_json + if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then + return 0 + fi + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" ]]; then + failed_jobs_json="$( + gh_with_retry run view "$run_id" --json jobs \ + --jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' + )" + elif ! failed_jobs_json="$( + fetch_child_jobs | + jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' + )"; then + echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." + return 0 + fi + if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then + # Advisory QA jobs are owned by the child's status-artifact verifier. + failed_jobs_json="$( + jq '[.[] | select( + ((.name | startswith("Run QA Lab parity lane (")) + or .name == "Run QA Lab parity report" + or (.name | startswith("Run QA Lab runtime-pair lane (")) + or .name == "Verify QA Lab runtime-pair lanes" + or .name == "Run QA Lab live Discord lane" + or .name == "Run QA Lab live WhatsApp lane" + or .name == "Run QA Lab live Slack lane") + | not)]' <<< "$failed_jobs_json" + )" + if [[ "$RELEASE_PROFILE" == "beta" ]]; then + # Beta live-provider and Telegram package checks are advisory; repo E2E is blocking. + failed_jobs_json="$( + jq '[.[] | select( + (((.name | startswith("Run repo/live E2E validation / ")) + and ((.name | contains("Docker live")) + or (.name | contains("Live media suites")) + or (.name | contains("validate_live_provider_suites")) + or (.name | contains("validate_release_live_cache")) + or (.name | contains("prepare_live_test_image")))) + or (.name | startswith("Run package acceptance / Telegram package acceptance / "))) + | not)]' <<< "$failed_jobs_json" + )" + fi + fi + if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then + if [[ "$workflow" == "npm-telegram-beta-e2e.yml" ]]; then + echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run." + else + echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." + fi + jq '.[] | {name, conclusion, url: (.url // .html_url)}' <<< "$failed_jobs_json" + cancel_child + trap - EXIT INT TERM + exit 1 + fi + } + poll_count=0 while true; do - status="$(fetch_child_run_json | jq -r '.status')" + status="$(read_child_run_field status)" if [[ "$status" == "completed" ]]; then break fi @@ -573,41 +674,204 @@ jobs: fi if (( poll_count % 10 == 0 )); then echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true + fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: (.url // .html_url)}' || true fi sleep 60 done trap - EXIT INT TERM - conclusion="$(fetch_child_run_json | jq -r '.conclusion // ""')" - url="$(fetch_child_run_json | jq -r '.html_url')" + if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then + jobs_json="$(fetch_child_jobs | jq -s '{jobs: [.[] | {name, conclusion, url: .html_url}]}')" + run_json="$( + jq -s '.[0] + .[1]' \ + <(fetch_child_run_json | jq '{conclusion: (.conclusion // ""), url: .html_url}') \ + <(printf '%s\n' "$jobs_json") + )" + conclusion="$(jq -r '.conclusion' <<< "$run_json")" + url="$(jq -r '.url' <<< "$run_json")" + else + conclusion="$(read_child_run_field conclusion)" + url="$(read_child_run_field html_url)" + fi echo "${workflow} finished with ${conclusion}: ${url}" echo "url=${url}" >> "$GITHUB_OUTPUT" echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: .html_url}' || true - exit 1 + if [[ "$conclusion" == "success" ]]; then + return 0 fi + if [[ "$workflow" == "openclaw-performance.yml" && "$RELEASE_PROFILE" == "beta" ]]; then + echo "::warning::OpenClaw Performance ended with ${conclusion}; advisory for beta: ${url}" + return 0 + fi + if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then + jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' <<< "$run_json" || true + if [[ "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]] && release_checks_advisory_only "$run_json"; then + echo "::warning::${workflow} ended with ${conclusion}, but Verify release checks accepted Tideclaw alpha advisory lanes." + return 0 + fi + else + if [[ "$workflow" == "openclaw-performance.yml" ]]; then + echo "::error::OpenClaw Performance ended with ${conclusion}: ${url}" + fi + fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: (.url // .html_url)}' || true + fi + exit 1 } - { - echo "### Normal CI" - echo - echo "- Target ref: \`${TARGET_REF}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - } >> "$GITHUB_STEP_SUMMARY" - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci" - dispatch_run_name="CI ${dispatch_id}" - args=(-f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id") - if [[ "$TARGET_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then - args+=(-f historical_target_tag="$TARGET_REF") - elif [[ "$TARGET_CONTEXT_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then - args+=(-f historical_target_tag="$TARGET_CONTEXT_REF") - elif [[ "$TARGET_CONTEXT_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then - args+=(-f release_candidate_ref="$TARGET_CONTEXT_REF") - fi - dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}" + case "$CHILD_WORKFLOW_KIND" in + ci) + { + echo "### Normal CI" + echo + echo "- Target ref: \`${TARGET_REF}\`" + echo "- Target SHA: \`${TARGET_SHA}\`" + } >> "$GITHUB_STEP_SUMMARY" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-ci" + dispatch_run_name="CI ${dispatch_id}" + args=(-f target_ref="$TARGET_SHA" -f include_android=true -f dispatch_id="$dispatch_id") + if [[ "$TARGET_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then + args+=(-f historical_target_tag="$TARGET_REF") + elif [[ "$TARGET_CONTEXT_REF" =~ ^v[0-9]{4}\.[0-9]+\.[0-9]+(-(alpha|beta)\.[0-9]+)?$ ]]; then + args+=(-f historical_target_tag="$TARGET_CONTEXT_REF") + elif [[ "$TARGET_CONTEXT_REF" =~ ^(release/[0-9]{4}\.[0-9]+\.[0-9]+|extended-stable/[0-9]{4}\.[0-9]+\.33)$ ]]; then + args+=(-f release_candidate_ref="$TARGET_CONTEXT_REF") + fi + dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}" + ;; + plugin-prerelease) + { + echo "### Plugin prerelease" + echo + echo "- Target ref: \`${TARGET_REF}\`" + echo "- Target SHA: \`${TARGET_SHA}\`" + } >> "$GITHUB_STEP_SUMMARY" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-plugin-prerelease" + dispatch_run_name="Plugin Prerelease ${dispatch_id}" + args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id") + if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then + args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") + fi + dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" "${args[@]}" + ;; + release-checks) + { + echo "### Release/live/Docker/QA validation" + echo + echo "- Target ref: \`${TARGET_REF}\`" + echo "- Target SHA: \`${TARGET_SHA}\`" + echo "- Provider: \`${PROVIDER}\`" + echo "- Cross-OS mode: \`${MODE}\`" + echo "- Release profile: \`${RELEASE_PROFILE}\`" + echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" + echo "- Rerun group: \`${RERUN_GROUP}\`" + if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then + echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" + fi + if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then + echo "- Cross-OS suite filter: \`${CROSS_OS_SUITE_FILTER}\`" + fi + if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then + echo "- Release package spec: \`${RELEASE_PACKAGE_SPEC}\`" + fi + if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then + echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`" + fi + if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then + echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`" + fi + } >> "$GITHUB_STEP_SUMMARY" + child_rerun_group="$RERUN_GROUP" + if [[ "$child_rerun_group" == "release-checks" ]]; then + child_rerun_group=all + fi + release_checks_target_ref="${TARGET_CONTEXT_REF:-$TARGET_REF}" + args=( + -f ref="$release_checks_target_ref" + -f expected_sha="$TARGET_SHA" + -f provider="$PROVIDER" + -f mode="$MODE" + -f release_profile="$RELEASE_PROFILE" + -f run_release_soak="$RUN_RELEASE_SOAK" + -f fail_fast="$FAIL_FAST" + -f allow_unreleased_changelog="$ALLOW_UNRELEASED_CHANGELOG" + -f rerun_group="$child_rerun_group" + ) + if [[ -n "${TARGET_CONTEXT_REF// }" ]]; then + args+=(-f allow_frozen_target_scenario_omissions=true) + fi + if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then + args+=(-f live_suite_filter="$LIVE_SUITE_FILTER") + fi + if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then + args+=(-f cross_os_suite_filter="$CROSS_OS_SUITE_FILTER") + fi + if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then + args+=(-f release_package_spec="$RELEASE_PACKAGE_SPEC") + fi + if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then + args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC") + fi + if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then + args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC") + fi + if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then + args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") + fi + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks" + dispatch_run_name="OpenClaw Release Checks ${dispatch_id}" + args+=(-f dispatch_id="$dispatch_id") + dispatch_and_wait openclaw-release-checks.yml "$dispatch_run_name" "${args[@]}" + ;; + npm-telegram) + args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE") + if [[ -n "${SCENARIO// }" ]]; then + args+=(-f scenario="$SCENARIO") + fi + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram" + dispatch_run_name="NPM Telegram Beta E2E ${dispatch_id}" + args+=(-f dispatch_id="$dispatch_id") + dispatch_and_wait npm-telegram-beta-e2e.yml "$dispatch_run_name" "${args[@]}" + ;; + performance) + fail_on_regression=true + if [[ "$RELEASE_PROFILE" == "beta" ]]; then + fail_on_regression=false + fi + { + echo "### Product performance" + echo + echo "- Target SHA: \`${TARGET_SHA}\`" + echo "- Profile: \`release\`" + echo "- Repeat: \`3\`" + echo "- Deep profile: \`false\`" + echo "- Live OpenAI candidate: \`false\`" + echo "- Regression gate: \`${fail_on_regression}\`" + echo "- Report publication: disabled (artifacts only)" + if [[ "$RELEASE_PROFILE" == "beta" ]]; then + echo "- Release impact: advisory" + else + echo "- Release impact: blocking" + fi + } >> "$GITHUB_STEP_SUMMARY" + dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" + dispatch_run_name="OpenClaw Performance ${dispatch_id}" + args=( + -f target_ref="$TARGET_SHA" + -f profile=release + -f repeat=3 + -f deep_profile=false + -f live_openai_candidate=false + -f fail_on_regression="$fail_on_regression" + -f publish_reports=false + -f dispatch_id="$dispatch_id" + ) + dispatch_and_wait openclaw-performance.yml "$dispatch_run_name" "${args[@]}" + ;; + *) + echo "::error::Unsupported full-release child workflow kind ${CHILD_WORKFLOW_KIND}." >&2 + exit 2 + ;; + esac plugin_prerelease: name: Run plugin prerelease validation @@ -624,184 +888,14 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: plugin-prerelease TARGET_REF: ${{ inputs.ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} FAIL_FAST: ${{ inputs.fail_fast }} - run: | - set -euo pipefail - - dispatch_and_wait() { - local workflow="$1" - local dispatch_run_name="$2" - shift 2 - - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count child_head_sha encoded_workflow_ref current_workflow_sha - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - return 1 - fi - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::${workflow} dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - - fetch_child_jobs() { - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - poll_count=0 - while true; do - status="$(fetch_child_run_json | jq -r '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - fi - if (( poll_count % 10 == 0 )); then - echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - conclusion="$(fetch_child_run_json | jq -r '.conclusion // ""')" - url="$(fetch_child_run_json | jq -r '.html_url')" - echo "${workflow} finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - fetch_child_jobs | jq 'select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url: .html_url}' || true - exit 1 - fi - } - - { - echo "### Plugin prerelease" - echo - echo "- Target ref: \`${TARGET_REF}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - } >> "$GITHUB_STEP_SUMMARY" - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-plugin-prerelease" - dispatch_run_name="Plugin Prerelease ${dispatch_id}" - args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id") - if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then - args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") - fi - dispatch_and_wait plugin-prerelease.yml "$dispatch_run_name" "${args[@]}" + run: *full_release_child_dispatch release_checks: name: Run release/live/Docker/QA validation @@ -820,6 +914,7 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: release-checks TARGET_REF: ${{ inputs.ref }} TARGET_CONTEXT_REF: ${{ inputs.target_context_ref }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} @@ -838,325 +933,7 @@ jobs: PACKAGE_ACCEPTANCE_PACKAGE_SPEC: ${{ inputs.package_acceptance_package_spec }} CODEX_PLUGIN_SPEC: ${{ inputs.codex_plugin_spec }} CANDIDATE_ARTIFACT_JSON: ${{ needs.prepare_release_candidate.outputs.candidate_artifact_json }} - run: | - set -euo pipefail - - dispatch_and_wait() { - local workflow="$1" - local dispatch_run_name="$2" - shift 2 - - local dispatch_output dispatch_status matches_json match_count run_id status conclusion url poll_count run_json child_head_sha encoded_workflow_ref current_workflow_sha - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - return 1 - fi - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::${workflow} dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::${workflow} dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - fetch_child_run_json() { - gh_with_retry api "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - } - - fetch_child_jobs() { - gh_with_retry api --paginate "repos/${GITHUB_REPOSITORY}/actions/runs/${run_id}/jobs?per_page=100" --jq '.jobs[]' - } - - release_check_blocking_job() { - if [[ "$RELEASE_PROFILE" == "beta" && "$1" == "Run package acceptance / Telegram package acceptance / "* ]]; then - return 1 - fi - case "$1" in - "resolve_target" | \ - "Prepare release package artifact" | \ - "install_smoke_release_checks / "* | \ - "Run package acceptance" | \ - "Run package acceptance / "*) - return 0 - ;; - esac - return 1 - } - - release_checks_advisory_only() { - local run_json="$1" - local verifier_conclusion name saw_advisory failed - - verifier_conclusion="$( - jq -r '.jobs[] | select(.name == "Verify release checks") | .conclusion' <<< "$run_json" | - tail -n 1 - )" - if [[ "$verifier_conclusion" != "success" ]]; then - return 1 - fi - - saw_advisory=0 - failed=0 - while IFS= read -r name; do - [[ -z "${name// }" ]] && continue - if release_check_blocking_job "$name"; then - echo "::error::${name} is a package-safety Tideclaw alpha release-check lane." - failed=1 - else - saw_advisory=1 - fi - done < <(jq -r '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | .name' <<< "$run_json") - - [[ "$saw_advisory" == "1" && "$failed" == "0" ]] - } - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - return 0 - fi - if ! failed_jobs_json="$( - fetch_child_jobs | - jq -s '[.[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )"; then - echo "::warning::Could not list ${workflow} child jobs; continuing with authoritative workflow conclusion." - return 0 - fi - if [[ "$workflow" == "openclaw-release-checks.yml" ]]; then - # These jobs are continue-on-error in the child workflow. Let its - # status-artifact verifier decide whether their evidence is usable. - failed_jobs_json="$( - jq '[.[] | select( - ((.name | startswith("Run QA Lab parity lane (")) - or .name == "Run QA Lab parity report" - or (.name | startswith("Run QA Lab runtime-pair lane (")) - or .name == "Verify QA Lab runtime-pair lanes" - or .name == "Run QA Lab live Discord lane" - or .name == "Run QA Lab live WhatsApp lane" - or .name == "Run QA Lab live Slack lane") - | not)]' <<< "$failed_jobs_json" - )" - fi - if [[ "$workflow" == "openclaw-release-checks.yml" && "$RELEASE_PROFILE" == "beta" ]]; then - # Beta treats live-provider suites as advisory (live_advisory in - # openclaw-live-and-e2e-checks-reusable.yml); their failures must - # not fail-fast-cancel the remaining release-check matrix. Repo - # E2E under the same caller stays blocking. - failed_jobs_json="$( - jq '[.[] | select( - (((.name | startswith("Run repo/live E2E validation / ")) - and ((.name | contains("Docker live")) - or (.name | contains("Live media suites")) - or (.name | contains("validate_live_provider_suites")) - or (.name | contains("validate_release_live_cache")) - or (.name | contains("prepare_live_test_image")))) - or (.name | startswith("Run package acceptance / Telegram package acceptance / "))) - | not)]' <<< "$failed_jobs_json" - )" - fi - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::${workflow} has failed child jobs before the workflow completed; cancelling the remaining matrix." - jq '.[] | {name, conclusion, url: .html_url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow ${workflow}: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(fetch_child_run_json | jq -r '.head_sha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::${workflow} child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - poll_count=0 - while true; do - status="$(fetch_child_run_json | jq -r '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - fi - if (( poll_count % 10 == 0 )); then - echo "Still waiting on ${workflow}: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - fetch_child_jobs | jq 'select(.status != "completed") | {name, status, url: .html_url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - jobs_json="$(fetch_child_jobs | jq -s '{jobs: [.[] | {name, conclusion, url: .html_url}]}')" - run_json="$( - jq -s '.[0] + .[1]' \ - <(fetch_child_run_json | jq '{conclusion: (.conclusion // ""), url: .html_url}') \ - <(printf '%s\n' "$jobs_json") - )" - conclusion="$(jq -r '.conclusion' <<< "$run_json")" - url="$(jq -r '.url' <<< "$run_json")" - echo "${workflow} finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' <<< "$run_json" || true - if [[ "$workflow" == "openclaw-release-checks.yml" && "$CHILD_WORKFLOW_REF" =~ ^tideclaw/alpha/[0-9]{4}-[0-9]{2}-[0-9]{2}-[0-9]{4}Z$ ]]; then - if release_checks_advisory_only "$run_json"; then - echo "::warning::${workflow} ended with ${conclusion}, but Verify release checks accepted Tideclaw alpha advisory lanes." - return 0 - fi - fi - exit 1 - fi - } - - { - echo "### Release/live/Docker/QA validation" - echo - echo "- Target ref: \`${TARGET_REF}\`" - echo "- Target SHA: \`${TARGET_SHA}\`" - echo "- Provider: \`${PROVIDER}\`" - echo "- Cross-OS mode: \`${MODE}\`" - echo "- Release profile: \`${RELEASE_PROFILE}\`" - echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`" - echo "- Rerun group: \`${RERUN_GROUP}\`" - if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then - echo "- Live suite filter: \`${LIVE_SUITE_FILTER}\`" - fi - if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then - echo "- Cross-OS suite filter: \`${CROSS_OS_SUITE_FILTER}\`" - fi - if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then - echo "- Release package spec: \`${RELEASE_PACKAGE_SPEC}\`" - fi - if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then - echo "- Package Acceptance package spec: \`${PACKAGE_ACCEPTANCE_PACKAGE_SPEC}\`" - fi - if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then - echo "- Codex plugin spec: \`${CODEX_PLUGIN_SPEC}\`" - fi - } >> "$GITHUB_STEP_SUMMARY" - - child_rerun_group="$RERUN_GROUP" - if [[ "$child_rerun_group" == "release-checks" ]]; then - child_rerun_group=all - fi - - release_checks_target_ref="${TARGET_CONTEXT_REF:-$TARGET_REF}" - - args=( - -f ref="$release_checks_target_ref" - -f expected_sha="$TARGET_SHA" - -f provider="$PROVIDER" - -f mode="$MODE" - -f release_profile="$RELEASE_PROFILE" - -f run_release_soak="$RUN_RELEASE_SOAK" - -f fail_fast="$FAIL_FAST" - -f allow_unreleased_changelog="$ALLOW_UNRELEASED_CHANGELOG" - -f rerun_group="$child_rerun_group" - ) - if [[ -n "${TARGET_CONTEXT_REF// }" ]]; then - args+=(-f allow_frozen_target_scenario_omissions=true) - fi - if [[ -n "${LIVE_SUITE_FILTER// }" ]]; then - args+=(-f live_suite_filter="$LIVE_SUITE_FILTER") - fi - if [[ -n "${CROSS_OS_SUITE_FILTER// }" ]]; then - args+=(-f cross_os_suite_filter="$CROSS_OS_SUITE_FILTER") - fi - if [[ -n "${RELEASE_PACKAGE_SPEC// }" ]]; then - args+=(-f release_package_spec="$RELEASE_PACKAGE_SPEC") - fi - if [[ -n "${PACKAGE_ACCEPTANCE_PACKAGE_SPEC// }" ]]; then - args+=(-f package_acceptance_package_spec="$PACKAGE_ACCEPTANCE_PACKAGE_SPEC") - fi - if [[ -n "${CODEX_PLUGIN_SPEC// }" ]]; then - args+=(-f codex_plugin_spec="$CODEX_PLUGIN_SPEC") - fi - if [[ -n "${CANDIDATE_ARTIFACT_JSON// }" ]]; then - args+=(-f candidate_artifact_json="$CANDIDATE_ARTIFACT_JSON") - fi - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-release-checks" - dispatch_run_name="OpenClaw Release Checks ${dispatch_id}" - args+=(-f dispatch_id="$dispatch_id") - dispatch_and_wait openclaw-release-checks.yml "$dispatch_run_name" "${args[@]}" + run: *full_release_child_dispatch npm_telegram: name: Run package Telegram E2E @@ -1174,6 +951,7 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: npm-telegram CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} @@ -1181,156 +959,7 @@ jobs: PROVIDER_MODE: ${{ inputs.npm_telegram_provider_mode }} SCENARIO: ${{ inputs.npm_telegram_scenario }} FAIL_FAST: ${{ inputs.fail_fast }} - run: | - set -euo pipefail - - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - exit 1 - fi - - args=(-f package_spec="$PACKAGE_SPEC" -f harness_ref="$TARGET_SHA" -f provider_mode="$PROVIDER_MODE") - if [[ -n "${SCENARIO// }" ]]; then - args+=(-f scenario="$SCENARIO") - fi - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram" - dispatch_run_name="NPM Telegram Beta E2E ${dispatch_id}" - args+=(-f dispatch_id="$dispatch_id") - - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::npm-telegram-beta-e2e.yml dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/npm-telegram-beta-e2e.yml/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::npm-telegram-beta-e2e.yml dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow npm-telegram-beta-e2e.yml: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::npm-telegram-beta-e2e.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - fail_fast_failed_jobs() { - if [[ "$FAIL_FAST" != "true" ]]; then - return 0 - fi - local failed_jobs_json - failed_jobs_json="$( - gh_with_retry run view "$run_id" --json jobs \ - --jq '[.jobs[] | select(.status == "completed" and .conclusion != "success" and .conclusion != "skipped")]' - )" - if jq -e 'length > 0' <<< "$failed_jobs_json" >/dev/null; then - echo "::error::npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run." - jq '.[] | {name, conclusion, url}' <<< "$failed_jobs_json" - cancel_child - trap - EXIT INT TERM - exit 1 - fi - } - - poll_count=0 - while true; do - status="$(gh_with_retry run view "$run_id" --json status --jq '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 5 == 0 )); then - fail_fast_failed_jobs - fi - if (( poll_count % 10 == 0 )); then - echo "Still waiting on npm-telegram-beta-e2e.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - conclusion="$(gh_with_retry run view "$run_id" --json conclusion --jq '.conclusion')" - url="$(gh_with_retry run view "$run_id" --json url --jq '.url')" - echo "npm-telegram-beta-e2e.yml finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true - exit 1 - fi + run: *full_release_child_dispatch performance: name: Run product performance evidence @@ -1349,170 +978,12 @@ jobs: id: dispatch env: GH_TOKEN: ${{ github.token }} + CHILD_WORKFLOW_KIND: performance RELEASE_PROFILE: ${{ inputs.release_profile }} TARGET_SHA: ${{ needs.resolve_target.outputs.sha }} CHILD_WORKFLOW_REF: ${{ github.ref_name }} PARENT_WORKFLOW_SHA: ${{ github.sha }} - run: | - set -euo pipefail - - gh_with_retry() { - local output status attempt - for attempt in 1 2 3 4 5 6; do - set +e - output="$(gh "$@" 2>&1)" - status=$? - set -e - if [[ "$status" -eq 0 ]]; then - printf '%s\n' "$output" - return 0 - fi - if [[ "$output" == *"Bad credentials"* || "$output" == *"HTTP 401"* || "$output" == *"secondary rate limit"* || "$output" == *"API rate limit"* || "$output" == *"HTTP 429"* || "$output" == *"abuse detection"* || "$output" == *"Sorry. Your account was suspended"* || "$output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::warning::gh $* failed on attempt ${attempt}: ${output}" >&2 - sleep $((attempt * 10)) - continue - fi - printf '%s\n' "$output" >&2 - return "$status" - done - printf '%s\n' "$output" >&2 - return "$status" - } - - encoded_workflow_ref="$(jq -rn --arg value "$CHILD_WORKFLOW_REF" '$value | @uri')" - current_workflow_sha="$( - gh_with_retry api "repos/${GITHUB_REPOSITORY}/commits/${encoded_workflow_ref}" --jq .sha - )" - if [[ "$current_workflow_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::Child workflow ref ${CHILD_WORKFLOW_REF} moved to ${current_workflow_sha}, expected ${PARENT_WORKFLOW_SHA}; refusing dispatch." >&2 - exit 1 - fi - - fail_on_regression=true - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - fail_on_regression=false - fi - - { - echo "### Product performance" - echo - echo "- Target SHA: \`${TARGET_SHA}\`" - echo "- Profile: \`release\`" - echo "- Repeat: \`3\`" - echo "- Deep profile: \`false\`" - echo "- Live OpenAI candidate: \`false\`" - echo "- Regression gate: \`${fail_on_regression}\`" - echo "- Report publication: disabled (artifacts only)" - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - echo "- Release impact: advisory" - else - echo "- Release impact: blocking" - fi - } >> "$GITHUB_STEP_SUMMARY" - - dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - dispatch_run_name="OpenClaw Performance ${dispatch_id}" - - # A failed dispatch POST can still create a run. Never retry it; recover only by exact run name. - set +e - dispatch_output="$(gh workflow run openclaw-performance.yml \ - --ref "$CHILD_WORKFLOW_REF" \ - -f target_ref="$TARGET_SHA" \ - -f profile=release \ - -f repeat=3 \ - -f deep_profile=false \ - -f live_openai_candidate=false \ - -f fail_on_regression="$fail_on_regression" \ - -f publish_reports=false \ - -f dispatch_id="$dispatch_id" 2>&1)" - dispatch_status=$? - set -e - printf '%s\n' "$dispatch_output" - - if [[ "$dispatch_status" -ne 0 && ! "$dispatch_output" =~ $GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ]]; then - echo "::error::openclaw-performance.yml dispatch failed with non-ambiguous status ${dispatch_status}; refusing adoption polling." >&2 - exit "$dispatch_status" - fi - - run_id="" - for _ in $(seq 1 60); do - if matches_json="$( - DISPATCH_RUN_NAME="$dispatch_run_name" CHILD_WORKFLOW_REF="$CHILD_WORKFLOW_REF" \ - gh_with_retry api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/openclaw-performance.yml/runs" \ - -F event=workflow_dispatch \ - -F per_page=100 \ - --jq '[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]' - )"; then - match_count="$(jq 'length' <<< "$matches_json")" - if (( match_count > 1 )); then - echo "::error::Multiple runs matched ${dispatch_run_name}; refusing to guess." >&2 - exit 1 - fi - if (( match_count == 1 )); then - run_id="$(jq -r '.[0]' <<< "$matches_json")" - break - fi - fi - sleep 5 - done - - if [[ -z "$run_id" ]]; then - echo "::error::Could not find exact dispatched run ${dispatch_run_name}; dispatch status ${dispatch_status}. The dispatch was not retried to avoid creating a duplicate child." >&2 - exit 1 - fi - if [[ "$dispatch_status" -ne 0 ]]; then - echo "::warning::openclaw-performance.yml dispatch returned status ${dispatch_status}; adopted exact run ${run_id}." >&2 - fi - - echo "Dispatched openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - echo "run_id=${run_id}" >> "$GITHUB_OUTPUT" - - cancel_child() { - if [[ -n "${run_id:-}" ]]; then - echo "Cancelling child workflow openclaw-performance.yml: ${run_id}" >&2 - gh run cancel "$run_id" >/dev/null 2>&1 || true - fi - } - trap cancel_child EXIT INT TERM - - child_head_sha="$(gh_with_retry run view "$run_id" --json headSha --jq '.headSha // ""')" - if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then - echo "::error::openclaw-performance.yml child run used workflow SHA ${child_head_sha}, expected parent workflow SHA ${PARENT_WORKFLOW_SHA}." - cancel_child - trap - EXIT INT TERM - exit 1 - fi - - poll_count=0 - while true; do - status="$(gh_with_retry run view "$run_id" --json status --jq '.status')" - if [[ "$status" == "completed" ]]; then - break - fi - poll_count=$((poll_count + 1)) - if (( poll_count % 10 == 0 )); then - echo "Still waiting on openclaw-performance.yml: https://github.com/${GITHUB_REPOSITORY}/actions/runs/${run_id}" - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.status != "completed") | {name, status, url}' || true - fi - sleep 60 - done - trap - EXIT INT TERM - - conclusion="$(gh_with_retry run view "$run_id" --json conclusion --jq '.conclusion')" - url="$(gh_with_retry run view "$run_id" --json url --jq '.url')" - echo "openclaw-performance.yml finished with ${conclusion}: ${url}" - echo "url=${url}" >> "$GITHUB_OUTPUT" - echo "conclusion=${conclusion}" >> "$GITHUB_OUTPUT" - if [[ "$conclusion" != "success" ]]; then - if [[ "$RELEASE_PROFILE" == "beta" ]]; then - echo "::warning::OpenClaw Performance ended with ${conclusion}; advisory for beta: ${url}" - exit 0 - fi - echo "::error::OpenClaw Performance ended with ${conclusion}: ${url}" - gh_with_retry run view "$run_id" --json jobs --jq '.jobs[] | select(.conclusion != "success" and .conclusion != "skipped") | {name, conclusion, url}' || true - exit 1 - fi - + run: *full_release_child_dispatch summary: name: Verify full validation needs: diff --git a/.github/workflows/openclaw-performance.yml b/.github/workflows/openclaw-performance.yml index b8a1b074873d..66aac720f846 100644 --- a/.github/workflows/openclaw-performance.yml +++ b/.github/workflows/openclaw-performance.yml @@ -70,7 +70,7 @@ env: OCM_VERSION: v0.2.29 OCM_LINUX_X64_SHA256: d966098d6ba2bc10891be3c76e162a37b07f28c4f51da75d2eb509886eb7e1cf KOVA_REPOSITORY: openclaw/Kova - KOVA_CANONICAL_CONFIG_REF: e2ff1b66e5597a0df2ddb50276257e36069513dd + KOVA_CANONICAL_CONFIG_REF: 283070760a16655b28835061774158b8b11b4aff KOVA_LEGACY_LIST_CONFIG_REF: f3d037b5b8aacd6adf8ef1dd2ea4c1d778ec7c6c PERFORMANCE_MODEL_ID: gpt-5.6-luna # Release matrices cold-build the candidate runtime before measurement. @@ -807,7 +807,8 @@ jobs: OPENCLAW_HOME="$gateway_home" OPENCLAW_STATE_DIR="$gateway_state" OPENCLAW_CONFIG_PATH="$gateway_config" OPENCLAW_GATEWAY_PORT="$gateway_port" \ node --import tsx scripts/bench-cli-startup.ts \ - --case gatewayHealthJson \ + --case gatewayHealthJsonConnected \ + --case gatewayHealthJsonFirstDevice \ --case configGetGatewayPort \ --runs "$source_runs" \ --warmup 1 \ diff --git a/config/max-lines-baseline.txt b/config/max-lines-baseline.txt index 6b9c6cd6c6b3..e582f0f34d97 100644 --- a/config/max-lines-baseline.txt +++ b/config/max-lines-baseline.txt @@ -975,7 +975,6 @@ ui/src/lib/skills/index.test.ts ui/src/lib/workboard/index.test.ts ui/src/pages/agents/agents-page.ts ui/src/pages/agents/memory/dreaming.test.ts -ui/src/pages/agents/memory/dreaming.ts ui/src/pages/agents/memory/view.ts ui/src/pages/agents/panels-tools-skills.ts ui/src/pages/chat/chat-command-executor.test.ts diff --git a/docs/gateway/clients.md b/docs/gateway/clients.md index 0369b63f4108..dbc85e097d63 100644 --- a/docs/gateway/clients.md +++ b/docs/gateway/clients.md @@ -111,6 +111,29 @@ Capability-gated agent tools are a separate use of the same declaration. If an agent tool requires a client capability, the Gateway omits that tool unless the originating client advertised every required capability. +## Validate attachments before sending + +Attachment limits are operator-tunable, so do not hardcode them. Read +`hello-ok.policy.attachments` and validate locally before uploading: + +```ts +const attachments = hello.policy.attachments; +if (attachments) { + const ceiling = isImage ? attachments.maxImageBytes : attachments.maxBytes; + if (file.byteLength > ceiling) rejectLocally(); +} +``` + +Both values are decoded per-attachment ceilings. Still check the serialized +request against `policy.maxPayload`: attachments travel as base64, so a file near +`maxBytes` can exceed the frame limit on its own. Older gateways omit +`policy.attachments`; when it is absent, send and handle the server outcome. +Accepted MIME types and per-message handling are not advertised because they +depend on the entrypoint and the resolved model. The gateway can return a typed +rejection, while text-only model runs can omit additional images after their +offload cap and still complete the request. The values are a connection-time +snapshot, so re-read them on every reconnect. + ## Recover state after reconnect Treat every successful reconnect as a new projection over durable history and diff --git a/docs/gateway/protocol.md b/docs/gateway/protocol.md index 7723fa50359c..14f440bcba45 100644 --- a/docs/gateway/protocol.md +++ b/docs/gateway/protocol.md @@ -153,7 +153,8 @@ Gateway responds with `hello-ok`: "policy": { "maxPayload": 26214400, "maxBufferedBytes": 52428800, - "tickIntervalMs": 15000 + "tickIntervalMs": 15000, + "attachments": { "maxBytes": 20971520, "maxImageBytes": 6291456 } } } } @@ -162,7 +163,32 @@ Gateway responds with `hello-ok`: `server`, `features`, `snapshot`, `policy`, and `auth` are all required by `HelloOkSchema` (`packages/gateway-protocol/src/schema/frames.ts`). `auth` reports the negotiated role/scopes even when no device token is issued (shape -above). `pluginSurfaceUrls` is optional and maps plugin surface names (e.g. +above). `policy.attachments` is optional (older gateways omit it) and advertises +the decoded-size ceilings chat attachments face on `chat.send`, `sessions.send`, +and session-creation initial turns: + +| Field | Meaning | +| --------------- | --------------------------------------------------------------------------------------------------- | +| `maxBytes` | Largest decoded size accepted for a single attachment (`agents.defaults.mediaMaxMb`, default 20 MB) | +| `maxImageBytes` | Largest decoded size accepted for a single image: `min(maxBytes, 6 MB agent-hydration cap)` | + +Validating before send: + +1. Check each file's decoded size against `maxImageBytes` for images and + `maxBytes` for everything else. +2. Serialize the whole request and check its encoded size against + `policy.maxPayload`. `policy.attachments` is a per-attachment ceiling, never a + promise the frame fits: attachments travel as base64, so a 20 MB file is about + 26.7 MB on the wire and exceeds the default 25 MiB frame limit on its own. +3. Treat the server as authoritative for everything else. Accepted MIME types and + per-message handling are deliberately not advertised because they depend on + the entrypoint, the resolved model, and payload sniffing. The gateway can + return a typed rejection, while text-only model runs can omit additional + images after their offload cap and still complete the request. +4. Re-read the values on every reconnect. They are a connection-time snapshot, so + a live `mediaMaxMb` edit reaches existing connections only after they reconnect. + +`pluginSurfaceUrls` is optional and maps plugin surface names (e.g. `canvas`) to scoped hosted URLs; it may expire, so nodes call `node.pluginSurface.refresh` with `{ "surface": "canvas" }` for a fresh entry. The deprecated `canvasHostUrl` / `canvasCapability` / `node.canvas.capability.refresh` @@ -1034,10 +1060,13 @@ third-party clients. | Default tick interval (pre `hello-ok`) | `30_000` ms | `packages/gateway-client/src/client.ts` | | Tick-timeout close | code `4000` when silence exceeds `tickIntervalMs * 2` | `packages/gateway-client/src/client.ts` | | `MAX_PAYLOAD_BYTES` | `25 * 1024 * 1024` (25 MB) | `src/gateway/server-constants.ts` | +| Chat attachment ceiling | `agents.defaults.mediaMaxMb`, default 20 MB decoded | `src/gateway/chat-attachment-policy.ts` | +| Chat attachment image ceiling | `min(attachment ceiling, 6 MB)` | `src/gateway/chat-attachment-policy.ts`, `packages/media-core/src/constants.ts` | The server advertises the effective `policy.tickIntervalMs`, -`policy.maxPayload`, and `policy.maxBufferedBytes` in `hello-ok`; clients -should honor those values rather than the pre-handshake defaults. +`policy.maxPayload`, `policy.maxBufferedBytes`, and `policy.attachments` in +`hello-ok`; clients should honor those values rather than the pre-handshake +defaults or hardcoded attachment sizes. The reference client lets finite requests own their configured deadline when every pending request has one. An `expectFinal` request without a finite diff --git a/extensions/feishu/src/streaming-card.error-release.test.ts b/extensions/feishu/src/streaming-card.error-release.test.ts new file mode 100644 index 000000000000..b540899387ca --- /dev/null +++ b/extensions/feishu/src/streaming-card.error-release.test.ts @@ -0,0 +1,151 @@ +// Feishu streaming card tests exercise error-path response body cancellation +// through a real guarded HTTP transport against a loopback server. +import { createServer, type Server } from "node:http"; +import type { AddressInfo } from "node:net"; +import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest"; + +const loopback = vi.hoisted(() => ({ + baseUrl: "", + releases: [] as Array<{ bodyIsNull: boolean; bodyUsed: boolean }>, + authStatus: 200, + createStatus: 200, + settingsStatus: 200, +})); + +vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + fetchWithSsrFGuard: async (...args: Parameters) => { + const [params] = args; + const url = new URL(params.url); + const redirected = new URL(`${url.pathname}${url.search}`, loopback.baseUrl).toString(); + const guarded = await actual.fetchWithSsrFGuard({ + ...params, + policy: { allowPrivateNetwork: true }, + url: redirected, + }); + return { + ...guarded, + release: async () => { + loopback.releases.push({ + bodyIsNull: guarded.response.body === null, + bodyUsed: guarded.response.bodyUsed, + }); + await guarded.release(); + }, + }; + }, + }; +}); + +const { FeishuStreamingSession } = await import("./streaming-card.js"); + +function writeJson(res: import("node:http").ServerResponse, payload: unknown, status = 200): void { + res.writeHead(status, { "content-type": "application/json" }); + res.end(JSON.stringify(payload)); +} + +let server: Server; + +beforeAll(async () => { + server = createServer((req, res) => { + const url = new URL(req.url ?? "/", "http://127.0.0.1"); + if (url.pathname.includes("/auth/")) { + if (loopback.authStatus === 200) { + writeJson(res, { code: 0, msg: "ok", tenant_access_token: "token", expire: 3600 }); + } else { + writeJson(res, { error: "tenant token rejected" }, loopback.authStatus); + } + return; + } + if (url.pathname.endsWith("/settings")) { + if (loopback.settingsStatus === 200) { + writeJson(res, { code: 0, msg: "ok" }); + } else { + writeJson(res, { error: "settings rejected" }, loopback.settingsStatus); + } + return; + } + if (loopback.createStatus === 200) { + writeJson(res, { code: 0, msg: "ok", data: { card_id: "card_1" } }); + } else { + writeJson(res, { error: "card create rejected" }, loopback.createStatus); + } + }); + await new Promise((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + const address = server.address() as AddressInfo; + loopback.baseUrl = `http://127.0.0.1:${address.port}`; +}); + +afterAll(async () => { + await new Promise((resolve) => { + server.close(() => resolve()); + }); +}); + +beforeEach(() => { + loopback.releases = []; + loopback.authStatus = 200; + loopback.createStatus = 200; + loopback.settingsStatus = 200; +}); + +describe("feishu streaming card error-path body release", () => { + it("cancels the unread tenant-token error body before release", async () => { + loopback.authStatus = 500; + const session = new FeishuStreamingSession({} as never, { + appId: "app_error_token", + appSecret: "secret", + }); + + await expect(session.start("chat_id", "open_id")).rejects.toThrow( + "Token request failed with HTTP 500", + ); + + expect(loopback.releases).toEqual([{ bodyIsNull: false, bodyUsed: true }]); + }); + + it("cancels the unread create-card error body before release", async () => { + loopback.createStatus = 500; + const session = new FeishuStreamingSession({} as never, { + appId: "app_error_create", + appSecret: "secret", + }); + + await expect(session.start("chat_id", "open_id")).rejects.toThrow( + "Create card request failed with HTTP 500", + ); + + expect(loopback.releases).toEqual([ + { bodyIsNull: false, bodyUsed: true }, + { bodyIsNull: false, bodyUsed: true }, + ]); + }); + + it("cancels the unread close-settings error body before release", async () => { + const client = { + im: { + message: { + create: async () => ({ code: 0, data: { message_id: "msg_1" } }), + }, + }, + }; + const session = new FeishuStreamingSession(client as never, { + appId: "app_error_close", + appSecret: "secret", + }); + await session.start("chat_id", "open_id"); + loopback.settingsStatus = 500; + + await expect(session.close()).resolves.toBe(false); + + expect(loopback.releases).toEqual([ + { bodyIsNull: false, bodyUsed: true }, + { bodyIsNull: false, bodyUsed: true }, + { bodyIsNull: false, bodyUsed: true }, + ]); + }); +}); diff --git a/extensions/feishu/src/streaming-card.ts b/extensions/feishu/src/streaming-card.ts index 086d805a56a9..88c00f972b92 100644 --- a/extensions/feishu/src/streaming-card.ts +++ b/extensions/feishu/src/streaming-card.ts @@ -128,12 +128,22 @@ function resolveAllowedHostnames(domain?: FeishuDomain): string[] { return ["open.feishu.cn"]; } +function cancelUnreadResponseBody(response: Response): void { + // A rejected response leaves its body unread; start cancellation before the + // guarded dispatcher is released so the connection is not leaked. Do not + // await: debug capture can tee the stream and deadlock a waiter. + if (!response.bodyUsed) { + void response.body?.cancel().catch(() => undefined); + } +} + async function assertSuccessfulCardKitResponse( response: Response, auditContext: string, action: string, ): Promise { if (!response.ok) { + cancelUnreadResponseBody(response); throw new Error(`${action} failed with HTTP ${response.status}`); } const data = await readFeishuJsonResponse(response, auditContext); @@ -174,6 +184,7 @@ async function getToken(creds: Credentials, deps?: FeishuStreamingDeps): Promise }; try { if (!response.ok) { + cancelUnreadResponseBody(response); throw new Error(`Token request failed with HTTP ${response.status}`); } data = await readFeishuJsonResponse(response, "feishu.streaming-card.token"); @@ -328,6 +339,7 @@ export class FeishuStreamingSession { }; try { if (!createRes.ok) { + cancelUnreadResponseBody(createRes); throw new Error(`Create card request failed with HTTP ${createRes.status}`); } createData = await readFeishuJsonResponse(createRes, "feishu.streaming-card.create"); diff --git a/extensions/google/google.live.test.ts b/extensions/google/google.live.test.ts index 302b213f3427..1522bf87d442 100644 --- a/extensions/google/google.live.test.ts +++ b/extensions/google/google.live.test.ts @@ -2,6 +2,7 @@ import { completeSimple, type Model } from "openclaw/plugin-sdk/llm"; import { resolveFfmpegBin } from "openclaw/plugin-sdk/media-runtime"; import { + createCapturedPluginRegistration, registerProviderPlugin, requireRegisteredProvider, } from "openclaw/plugin-sdk/plugin-test-runtime"; @@ -102,6 +103,16 @@ const registerGooglePlugin = () => name: "Google Provider", }); +function registerGoogleRealtimeVoiceProvider() { + const captured = createCapturedPluginRegistration({ + id: "google", + name: "Google Provider", + source: "test", + }); + plugin.register(captured.api); + return requireRegisteredProvider(captured.realtimeVoiceProviders, "google"); +} + describeLive("google plugin live", () => { it.each(["gemini-3.6-flash", "gemini-3.5-flash-lite"])( "discovers and completes through %s", @@ -291,6 +302,94 @@ describeLive("google plugin live", () => { expect(closeReasons).toEqual(["completed"]); }, 120_000); + it("delivers a queued prompt through the registered lazy realtime bridge", async () => { + const provider = registerGoogleRealtimeVoiceProvider(); + const finalAssistantTranscripts: string[] = []; + const errors: Error[] = []; + const closeReasons: string[] = []; + let outputAudioBytes = 0; + let assistantPartialCount = 0; + let lastAssistantOutputAt = 0; + let readyCount = 0; + const bridge: RealtimeVoiceBridge = provider.createBridge({ + providerConfig: { apiKey: GOOGLE_API_KEY }, + instructions: "Reply briefly and plainly.", + onAudio: (audio) => { + outputAudioBytes += audio.byteLength; + lastAssistantOutputAt = Date.now(); + }, + onClearAudio: () => {}, + onTranscript: (role, text, isFinal) => { + if (role !== "assistant") { + return; + } + if (isFinal) { + finalAssistantTranscripts.push(text); + } else { + assistantPartialCount += 1; + } + lastAssistantOutputAt = Date.now(); + }, + onReady: () => { + readyCount += 1; + }, + onError: (error) => errors.push(error), + onClose: (reason) => closeReasons.push(reason), + }); + const describeState = () => ({ + readyCount, + connected: bridge.isConnected(), + outputAudioBytes, + assistantPartialCount, + assistantIdleMs: lastAssistantOutputAt === 0 ? 0 : Date.now() - lastAssistantOutputAt, + assistantFinalCount: finalAssistantTranscripts.length, + errors: errors.map(shortGoogleLiveError), + closeReasons, + }); + + bridge.sendUserMessage?.("Reply with exactly: OpenClaw lazy bridge ready."); + try { + await bridge.connect(); + // Gemini 3.1 can omit transcription.finished. Wait for output to go idle + // before close terminalizes the buffered transcript. + await waitForGoogleLive( + "queued assistant response drain", + () => + outputAudioBytes > 0 && + assistantPartialCount > 0 && + Date.now() - lastAssistantOutputAt >= 1_000, + 45_000, + describeState, + ); + expect(readyCount).toBe(1); + expect(bridge.isConnected()).toBe(true); + expect(outputAudioBytes).toBeGreaterThan(0); + expect(assistantPartialCount).toBeGreaterThan(0); + expect(errors).toStrictEqual([]); + } finally { + bridge.close(); + bridge.close(); + } + + await waitForGoogleLive( + "queued final transcript and clean close", + () => finalAssistantTranscripts.length > 0 && closeReasons.length === 1, + 5_000, + describeState, + ); + expect( + finalAssistantTranscripts.some((text) => { + const normalized = normalizeTranscriptForMatch(text); + return ( + normalized.includes("openclaw") && + normalized.includes("lazy") && + normalized.includes("bridge") + ); + }), + ).toBe(true); + expect(closeReasons).toEqual(["completed"]); + }, 120_000); + it("runs Gemini web search through the registered provider tool", async () => { const provider = createGeminiWebSearchProvider(); const tool = provider.createTool?.({ diff --git a/extensions/google/index.test.ts b/extensions/google/index.test.ts index 1126fdfc7ced..118cc4cadc5c 100644 --- a/extensions/google/index.test.ts +++ b/extensions/google/index.test.ts @@ -13,13 +13,29 @@ import { requireRegisteredProvider, } from "openclaw/plugin-sdk/plugin-test-runtime"; import { createCapturedThinkingConfigStream } from "openclaw/plugin-sdk/provider-test-contracts"; -import type { RealtimeVoiceProviderPlugin } from "openclaw/plugin-sdk/realtime-voice"; -import { describe, expect, it } from "vitest"; +import type { + RealtimeVoiceBridge, + RealtimeVoiceBridgeCreateRequest, + RealtimeVoiceProviderPlugin, +} from "openclaw/plugin-sdk/realtime-voice"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { registerGoogleGeminiCliProvider } from "./gemini-cli-provider.js"; import googlePlugin from "./index.js"; import googleProviderDiscovery from "./provider-discovery.js"; import { registerGoogleProvider } from "./provider-registration.js"; +const { createRealtimeBridgeMock } = vi.hoisted(() => ({ + createRealtimeBridgeMock: vi.fn<(req: RealtimeVoiceBridgeCreateRequest) => RealtimeVoiceBridge>(), +})); + +vi.mock("./realtime-voice-provider.js", () => ({ + buildGoogleRealtimeVoiceProvider: () => ({ + id: "google", + label: "Google Live Voice", + createBridge: createRealtimeBridgeMock, + }), +})); + const googleProviderPlugin = { register(api: Parameters[0]) { registerGoogleProvider(api); @@ -27,7 +43,73 @@ const googleProviderPlugin = { }, }; +function createDeferred() { + let resolve!: (value: T | PromiseLike) => void; + let reject!: (reason?: unknown) => void; + const promise = new Promise((resolvePromise, rejectPromise) => { + resolve = resolvePromise; + reject = rejectPromise; + }); + return { promise, resolve, reject }; +} + +function createMockRealtimeBridge(connectImpl: () => Promise = async () => {}) { + const connect = vi.fn(connectImpl); + const sendUserMessage = vi.fn(); + const triggerGreeting = vi.fn(); + const close = vi.fn(); + const bridge: RealtimeVoiceBridge = { + supportsToolResultContinuation: false, + supportsToolResultSuppression: false, + connect, + sendAudio: vi.fn(), + setMediaTimestamp: vi.fn(), + sendUserMessage, + triggerGreeting, + handleBargeIn: vi.fn(), + submitToolResult: vi.fn(), + acknowledgeMark: vi.fn(), + close, + isConnected: vi.fn(() => false), + }; + return { bridge, close, connect, sendUserMessage, triggerGreeting }; +} + +function createLazyRealtimeBridge(onError = vi.fn(), onReady?: () => void) { + let realtimeProvider: RealtimeVoiceProviderPlugin | undefined; + googlePlugin.register( + createTestPluginApi({ + registerRealtimeVoiceProvider(provider) { + realtimeProvider = provider; + }, + }), + ); + const bridge = realtimeProvider?.createBridge({ + providerConfig: { apiKey: "gemini-key" }, + onAudio() {}, + onClearAudio() {}, + onError, + onReady, + }); + if (!bridge) { + throw new Error("expected Google realtime bridge"); + } + return { bridge, onError }; +} + +function signalRealtimeBridgeReady() { + const request = createRealtimeBridgeMock.mock.calls.at(-1)?.[0]; + if (!request) { + throw new Error("expected Google realtime bridge request"); + } + request.onReady?.(); +} + describe("google provider plugin hooks", () => { + beforeEach(() => { + createRealtimeBridgeMock.mockReset(); + }); + it("owns replay policy and reasoning mode for the direct Gemini provider", async () => { const { providers } = await registerProviderPlugin({ plugin: googleProviderPlugin, @@ -397,28 +479,135 @@ describe("google provider plugin hooks", () => { }); it("buffers early realtime audio while the lazy Google bridge loads", () => { - let realtimeProvider: RealtimeVoiceProviderPlugin | undefined; - googlePlugin.register( - createTestPluginApi({ - registerRealtimeVoiceProvider(provider) { - realtimeProvider = provider; - }, - }), - ); - - const bridge = realtimeProvider?.createBridge({ - providerConfig: { apiKey: "gemini-key" }, - onAudio() {}, - onClearAudio() {}, - }); - - if (!bridge) { - throw new Error("expected Google realtime bridge"); - } + const { bridge } = createLazyRealtimeBridge(); expect(bridge.supportsToolResultContinuation).toBe(false); expect(bridge.supportsToolResultSuppression).toBe(false); expect(bridge.sendAudio(Buffer.alloc(160))).toBeUndefined(); expect(bridge.setMediaTimestamp(20)).toBeUndefined(); expect(bridge.sendUserMessage?.("hello")).toBeUndefined(); }); + + it("preserves queued user messages until the loaded bridge reports ready", async () => { + const connected = createDeferred(); + const loaded = createMockRealtimeBridge(() => connected.promise); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge } = createLazyRealtimeBridge(); + + bridge.sendUserMessage?.("before connect"); + const connectPromise = bridge.connect(); + await vi.waitFor(() => expect(loaded.connect).toHaveBeenCalledOnce()); + bridge.sendUserMessage?.("during connect"); + + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + connected.resolve(); + await connectPromise; + + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + signalRealtimeBridgeReady(); + + expect(loaded.sendUserMessage.mock.calls.map(([text]) => text)).toEqual([ + "before connect", + "during connect", + ]); + }); + + it("rejects each user message beyond the lazy startup queue count", async () => { + const loaded = createMockRealtimeBridge(); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge, onError } = createLazyRealtimeBridge(); + + for (let index = 0; index < 130; index += 1) { + bridge.sendUserMessage?.(`message-${index}`); + } + await bridge.connect(); + signalRealtimeBridgeReady(); + + expect(loaded.sendUserMessage).toHaveBeenCalledTimes(128); + expect(loaded.sendUserMessage.mock.calls.map(([text]) => text)).toEqual( + Array.from({ length: 128 }, (_, index) => `message-${index}`), + ); + expect(onError).toHaveBeenCalledTimes(2); + expect(onError).toHaveBeenNthCalledWith( + 1, + expect.objectContaining({ message: expect.stringContaining("queue overflow") }), + ); + expect(onError).toHaveBeenNthCalledWith( + 2, + expect.objectContaining({ message: expect.stringContaining("queue overflow") }), + ); + }); + + it("bounds the lazy startup queue by aggregate UTF-8 bytes", async () => { + const loaded = createMockRealtimeBridge(); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge, onError } = createLazyRealtimeBridge(); + const exactLimit = "🙂".repeat((256 * 1024) / 4); + + expect(Buffer.byteLength(exactLimit, "utf8")).toBe(256 * 1024); + bridge.sendUserMessage?.(exactLimit); + bridge.sendUserMessage?.("overflow"); + await bridge.connect(); + signalRealtimeBridgeReady(); + + expect(loaded.sendUserMessage).toHaveBeenCalledOnce(); + expect(loaded.sendUserMessage).toHaveBeenCalledWith(exactLimit); + expect(onError).toHaveBeenCalledOnce(); + }); + + it("closes a bridge that loads after the lazy wrapper is closed", async () => { + const loaded = createMockRealtimeBridge(); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge } = createLazyRealtimeBridge(); + + bridge.sendUserMessage?.("before connect"); + const connectPromise = bridge.connect(); + bridge.close(); + bridge.close(); + bridge.sendUserMessage?.("after close"); + await connectPromise; + + expect(loaded.connect).not.toHaveBeenCalled(); + expect(loaded.close).toHaveBeenCalledOnce(); + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + }); + + it("clears queued messages and ignores a late connect completion after close", async () => { + const connected = createDeferred(); + const loaded = createMockRealtimeBridge(() => connected.promise); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const { bridge } = createLazyRealtimeBridge(); + + bridge.sendUserMessage?.("before connect"); + const connectPromise = bridge.connect(); + await vi.waitFor(() => expect(loaded.connect).toHaveBeenCalledOnce()); + bridge.sendUserMessage?.("during connect"); + bridge.close(); + bridge.close(); + bridge.sendUserMessage?.("after close"); + connected.resolve(); + await connectPromise; + signalRealtimeBridgeReady(); + + expect(loaded.close).toHaveBeenCalledOnce(); + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + }); + + it("keeps close precedence when the readiness callback closes the lazy bridge", async () => { + const loaded = createMockRealtimeBridge(); + createRealtimeBridgeMock.mockReturnValue(loaded.bridge); + const bridgeRef: { current?: RealtimeVoiceBridge } = {}; + const onReady = vi.fn(() => bridgeRef.current?.close()); + const { bridge } = createLazyRealtimeBridge(vi.fn(), onReady); + bridgeRef.current = bridge; + + bridge.sendUserMessage?.("queued prompt"); + bridge.triggerGreeting?.("queued greeting"); + await bridge.connect(); + signalRealtimeBridgeReady(); + + expect(onReady).toHaveBeenCalledOnce(); + expect(loaded.close).toHaveBeenCalledOnce(); + expect(loaded.sendUserMessage).not.toHaveBeenCalled(); + expect(loaded.triggerGreeting).not.toHaveBeenCalled(); + }); }); diff --git a/extensions/google/index.ts b/extensions/google/index.ts index a53362e59fbf..619371579db0 100644 --- a/extensions/google/index.ts +++ b/extensions/google/index.ts @@ -202,24 +202,56 @@ function resolveGoogleRealtimeEnvApiKey(): string | undefined { } const GOOGLE_REALTIME_LAZY_MAX_PENDING_AUDIO_CHUNKS = 320; +const GOOGLE_REALTIME_LAZY_MAX_PENDING_USER_MESSAGES = 128; +const GOOGLE_REALTIME_LAZY_MAX_PENDING_USER_MESSAGE_BYTES = 256 * 1024; function createLazyGoogleRealtimeVoiceBridge( req: RealtimeVoiceBridgeCreateRequest, ): RealtimeVoiceBridge { let bridge: RealtimeVoiceBridge | undefined; let bridgePromise: Promise | undefined; + let bridgeReady = false; + let bridgeClosed = false; let closed = false; let latestMediaTimestamp: number | undefined; let pendingGreeting: string | undefined; const pendingAudio: Buffer[] = []; const pendingUserMessages: string[] = []; + let pendingUserMessageBytes = 0; + // Loading and connecting finish on separate async boundaries. Keep close ownership + // here so either late completion closes the provider bridge exactly once. + const closeBridge = (loadedBridge = bridge) => { + if (!loadedBridge || bridgeClosed) { + return; + } + bridgeClosed = true; + loadedBridge.close(); + }; const loadBridge = async () => { if (!bridgePromise) { bridgePromise = loadGoogleRealtimeVoiceProvider().then((provider) => - provider.createBridge(req), + provider.createBridge({ + ...req, + onReady: () => { + if (closed) { + return; + } + req.onReady?.(); + if (closed || !bridge) { + return; + } + bridgeReady = true; + // `connect()` and provider readiness are separate lifecycle facts. + // Release prompts only after the provider can accept user content. + flushPending(bridge); + }, + }), ); } bridge = await bridgePromise; + if (closed) { + closeBridge(bridge); + } return bridge; }; const requireBridge = () => { @@ -229,13 +261,18 @@ function createLazyGoogleRealtimeVoiceBridge( return bridge; }; const flushPending = (loadedBridge: RealtimeVoiceBridge) => { + if (closed) { + return; + } if (typeof latestMediaTimestamp === "number") { loadedBridge.setMediaTimestamp(latestMediaTimestamp); } for (const audio of pendingAudio.splice(0)) { loadedBridge.sendAudio(audio); } - for (const text of pendingUserMessages.splice(0)) { + const userMessages = pendingUserMessages.splice(0); + pendingUserMessageBytes = 0; + for (const text of userMessages) { loadedBridge.sendUserMessage?.(text); } if (pendingGreeting !== undefined) { @@ -252,45 +289,64 @@ function createLazyGoogleRealtimeVoiceBridge( connect: async () => { const loadedBridge = await loadBridge(); if (closed) { - loadedBridge.close(); + closeBridge(loadedBridge); return; } await loadedBridge.connect(); - flushPending(loadedBridge); + if (closed) { + closeBridge(loadedBridge); + } }, sendAudio: (audio) => { + if (closed) { + return; + } if (bridge) { bridge.sendAudio(audio); return; } - if (!closed) { - if (pendingAudio.length >= GOOGLE_REALTIME_LAZY_MAX_PENDING_AUDIO_CHUNKS) { - pendingAudio.shift(); - } - pendingAudio.push(audio); + if (pendingAudio.length >= GOOGLE_REALTIME_LAZY_MAX_PENDING_AUDIO_CHUNKS) { + pendingAudio.shift(); } + pendingAudio.push(audio); }, setMediaTimestamp: (ts) => { + if (closed) { + return; + } latestMediaTimestamp = ts; bridge?.setMediaTimestamp(ts); }, sendUserMessage: (text) => { - if (bridge) { + if (closed) { + return; + } + if (bridgeReady && bridge) { bridge.sendUserMessage?.(text); return; } - if (!closed) { - pendingUserMessages.push(text); + const messageBytes = Buffer.byteLength(text, "utf8"); + if ( + pendingUserMessages.length >= GOOGLE_REALTIME_LAZY_MAX_PENDING_USER_MESSAGES || + pendingUserMessageBytes + messageBytes > GOOGLE_REALTIME_LAZY_MAX_PENDING_USER_MESSAGE_BYTES + ) { + req.onError?.( + new Error("Google realtime voice pending user message queue overflow during startup"), + ); + return; } + pendingUserMessages.push(text); + pendingUserMessageBytes += messageBytes; }, triggerGreeting: (instructions) => { - if (bridge) { + if (closed) { + return; + } + if (bridgeReady && bridge) { bridge.triggerGreeting?.(instructions); return; } - if (!closed) { - pendingGreeting = instructions; - } + pendingGreeting = instructions; }, handleBargeIn: (options) => requireBridge().handleBargeIn?.(options), submitToolResult: (callId, result, options) => @@ -298,10 +354,12 @@ function createLazyGoogleRealtimeVoiceBridge( acknowledgeMark: () => requireBridge().acknowledgeMark(), close: () => { closed = true; + bridgeReady = false; pendingAudio.length = 0; pendingUserMessages.length = 0; + pendingUserMessageBytes = 0; pendingGreeting = undefined; - bridge?.close(); + closeBridge(); }, isConnected: () => bridge?.isConnected() ?? false, }; diff --git a/extensions/googlechat/src/api.ts b/extensions/googlechat/src/api.ts index bd73b05702f8..8bc04101c8e7 100644 --- a/extensions/googlechat/src/api.ts +++ b/extensions/googlechat/src/api.ts @@ -178,6 +178,19 @@ async function fetchBuffer( }); } +/** + * A Google Chat `thread` must be a `spaces/{space}/threads/{thread}` resource + * name that belongs to the target space. Reply routing sometimes yields other + * shapes — a bare id, a `spaces/{space}/messages/{message}` name, or a thread + * from a different (or wrongly-cased) space — and passing any of those makes the + * Chat API reject the whole send with `400 INVALID_ARGUMENT`. Accept only a + * well-formed, same-space thread name; callers drop the rest so the message + * still delivers to the space (as a new thread) instead of failing outright. + */ +function isUsableGoogleChatThreadName(thread: string, space: string): boolean { + return /^spaces\/[^/]+\/threads\/[^/]+$/.test(thread) && thread.startsWith(`${space}/threads/`); +} + export async function sendGoogleChatMessage(params: { account: ResolvedGoogleChatAccount; space: string; @@ -186,6 +199,7 @@ export async function sendGoogleChatMessage(params: { cardsV2?: GoogleChatCardV2[]; }): Promise<{ messageName?: string; threadName?: string } | null> { const { account, space, text, thread, cardsV2 } = params; + const usableThread = thread && isUsableGoogleChatThreadName(thread, space) ? thread : undefined; if ( text && (!cardsV2 || cardsV2.length === 0) && @@ -200,11 +214,11 @@ export async function sendGoogleChatMessage(params: { if (cardsV2 && cardsV2.length > 0) { body.cardsV2 = cardsV2; } - if (thread) { - body.thread = { name: thread }; + if (usableThread) { + body.thread = { name: usableThread }; } const urlObj = new URL(`${CHAT_API_BASE}/${space}/messages`); - if (thread) { + if (usableThread) { urlObj.searchParams.set("messageReplyOption", "REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); } const url = urlObj.toString(); diff --git a/extensions/googlechat/src/targets.test.ts b/extensions/googlechat/src/targets.test.ts index d9db2f68e865..a315bf0cba0c 100644 --- a/extensions/googlechat/src/targets.test.ts +++ b/extensions/googlechat/src/targets.test.ts @@ -568,6 +568,58 @@ describe("sendGoogleChatMessage", () => { expect(String(url)).not.toContain("messageReplyOption="); }); + it.each([ + ["a bare id", "113887189178345237288721356"], + ["a thread key without prefix", "pytxeqyhqck"], + ["a message resource name", "spaces/AAA/messages/1720896000000.000000"], + ["a space resource name", "spaces/AAA"], + ["a thread from a different space", "spaces/BBB/threads/xyz"], + ])( + "drops an invalid thread resource name (%s) and posts to the space", + async (_label, badThread) => { + const fetchMock = stubSuccessfulSend("spaces/AAA/messages/126"); + + const result = await sendGoogleChatMessage({ + account, + space: "spaces/AAA", + text: "hello", + thread: badThread, + }); + + const url = mockCallArg(fetchMock); + const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined; + // Invalid thread must not be forwarded, and the reply option must be omitted + // so the Chat API accepts the send instead of returning 400 INVALID_ARGUMENT. + expect(String(url)).not.toContain("messageReplyOption="); + if (typeof init?.body !== "string") { + throw new Error("Expected Google Chat request body"); + } + const body = JSON.parse(init.body) as { thread?: unknown }; + expect(body.thread).toBeUndefined(); + expect(result).toEqual({ messageName: "spaces/AAA/messages/126" }); + }, + ); + + it("keeps a valid same-space thread resource name", async () => { + const fetchMock = stubSuccessfulSend("spaces/AAA/messages/127", "spaces/AAA/threads/xyz"); + + await sendGoogleChatMessage({ + account, + space: "spaces/AAA", + text: "hello", + thread: "spaces/AAA/threads/xyz", + }); + + const url = mockCallArg(fetchMock); + const init = mockCallArg(fetchMock, 0, 1) as RequestInit | undefined; + expect(String(url)).toContain("messageReplyOption=REPLY_MESSAGE_FALLBACK_TO_NEW_THREAD"); + if (typeof init?.body !== "string") { + throw new Error("Expected Google Chat request body"); + } + const body = JSON.parse(init.body) as { thread?: { name?: unknown } }; + expect(body.thread?.name).toBe("spaces/AAA/threads/xyz"); + }); + it("sends cardsV2 with the text fallback", async () => { const fetchMock = stubSuccessfulSend("spaces/AAA/messages/125"); const cardsV2 = [ diff --git a/extensions/imessage/src/constants.ts b/extensions/imessage/src/constants.ts index cc3035aa3289..d36cbb05d65b 100644 --- a/extensions/imessage/src/constants.ts +++ b/extensions/imessage/src/constants.ts @@ -1,11 +1,8 @@ /** Default timeout for iMessage probe/RPC operations (10 seconds). */ export const DEFAULT_IMESSAGE_PROBE_TIMEOUT_MS = 10_000; -// Sends get a much longer default than probes: on macOS 26 (Tahoe) the private -// API bridge intermittently stalls up to ~124s before the send completes. The -// 10s probe timeout aborts those mid-flight, and non-recoverable shapes -// (attachment/reply) are then lost. This must clear the observed upper bound -// plus headroom, otherwise the long tail of stalls still loses sends — 150s -// covers 124s with margin. Decoupling keeps probes/health checks fast while -// letting real sends ride out the stall. Akin to the BlueBubbles fix (#69193). -export const DEFAULT_IMESSAGE_SEND_TIMEOUT_MS = 150_000; +// imsg waits up to 150s for a private-bridge send, then auto transport can fall +// back to AppleScript and spend up to 8s verifying the persisted row. The outer +// RPC timeout must outlive both stages; matching imsg's 150s deadline turns a +// successful fallback into an ambiguous timeout that callers may retry. +export const DEFAULT_IMESSAGE_SEND_TIMEOUT_MS = 180_000; diff --git a/extensions/imessage/src/send.test.ts b/extensions/imessage/src/send.test.ts index 37b5979a47c8..42df916e1efd 100644 --- a/extensions/imessage/src/send.test.ts +++ b/extensions/imessage/src/send.test.ts @@ -1709,17 +1709,46 @@ describe("sendMessageIMessage receipts", () => { ); }); - it("uses the dedicated send timeout (covers macOS 26 stalls), not the 10s probe default", async () => { - const client = createClient({ guid: "p:0/imsg-1" }); + it("floors a configured probe timeout so one delayed imsg fallback can resolve", async () => { + vi.useFakeTimers(); + const delayedFallbackMs = 158_000; + const client = { + request: vi.fn( + (_method: string, _params: Record, opts?: { timeoutMs?: number }) => + new Promise>((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error("imsg rpc timeout (send)")), + opts?.timeoutMs, + ); + setTimeout(() => { + clearTimeout(timeout); + resolve({ guid: "p:0/imsg-delayed-fallback" }); + }, delayedFallbackMs); + }), + ), + stop: vi.fn(async () => {}), + } as unknown as IMessageRpcClient; - await sendMessageIMessage("chat_id:42", "hello", { - config: IMESSAGE_TEST_CFG, + const send = sendMessageIMessage("chat_id:42", "hello", { + config: { + channels: { + imessage: { + ...IMESSAGE_TEST_CFG.channels.imessage, + probeTimeoutMs: 10_000, + }, + }, + }, client, }); + await vi.advanceTimersByTimeAsync(delayedFallbackMs); - expect(getClientMocks(client).request).toHaveBeenCalledWith("send", expect.any(Object), { - timeoutMs: 150_000, - }); + await expect(send).resolves.toMatchObject({ messageId: "p:0/imsg-delayed-fallback" }); + expect(getClientMocks(client).request).toHaveBeenCalledTimes(1); + expect(getClientMocks(client).request).toHaveBeenCalledWith( + "send", + expect.any(Object), + expect.objectContaining({ timeoutMs: 180_000 }), + ); }); it("sends explicit chat media-only payloads through send-attachment auto transport", async () => { diff --git a/extensions/imessage/src/send.ts b/extensions/imessage/src/send.ts index a69d2a178627..e90ee9245692 100644 --- a/extensions/imessage/src/send.ts +++ b/extensions/imessage/src/send.ts @@ -768,11 +768,12 @@ export async function sendMessageIMessage( replyToId: opts.replyToId, conversationReadOrigin: opts.conversationReadOrigin, }); - // Sends use a dedicated longer default (not the 10s probe timeout) so macOS 26 - // bridge stalls aren't aborted mid-send. Explicit opts/probeTimeoutMs still win - // for callers that tuned them. See DEFAULT_IMESSAGE_SEND_TIMEOUT_MS. + // Sends use a dedicated longer floor (not the 10s probe timeout) so macOS 26 + // bridge stalls aren't aborted mid-send. A configured probe timeout may extend + // sends, but only an explicit per-call timeout may shorten them. const timeoutMs = - opts.timeoutMs ?? account.config.probeTimeoutMs ?? DEFAULT_IMESSAGE_SEND_TIMEOUT_MS; + opts.timeoutMs ?? + Math.max(account.config.probeTimeoutMs ?? 0, DEFAULT_IMESSAGE_SEND_TIMEOUT_MS); const pendingEchoTtlMs = resolvePendingPersistedEchoTtlMs(timeoutMs); const region = opts.region?.trim() || account.config.region?.trim() || "US"; const maxBytes = diff --git a/extensions/irc/src/send.test.ts b/extensions/irc/src/send.test.ts index 09bb36edc79c..e18cf5522dd7 100644 --- a/extensions/irc/src/send.test.ts +++ b/extensions/irc/src/send.test.ts @@ -10,11 +10,13 @@ const hoisted = vi.hoisted(() => { const loadConfig = vi.fn(); const resolveMarkdownTableMode = vi.fn(() => "preserve"); const convertMarkdownTables = vi.fn((text: string) => text); + const stripMarkdown = vi.fn((text: string) => text); const record = vi.fn(); return { loadConfig, resolveMarkdownTableMode, convertMarkdownTables, + stripMarkdown, record, normalizeIrcMessagingTarget: vi.fn((value: string) => value.trim()), connectIrcClient: vi.fn(), @@ -47,6 +49,14 @@ vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => { string, unknown >; + return original; +}); + +vi.mock("openclaw/plugin-sdk/markdown-table-runtime", async () => { + const original = (await vi.importActual("openclaw/plugin-sdk/markdown-table-runtime")) as Record< + string, + unknown + >; return { ...original, resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode, @@ -61,6 +71,7 @@ vi.mock("openclaw/plugin-sdk/text-chunking", async () => { return { ...original, convertMarkdownTables: hoisted.convertMarkdownTables, + stripMarkdown: hoisted.stripMarkdown, }; }); @@ -71,6 +82,7 @@ function resetHoistedMocks() { hoisted.loadConfig.mockReset(); hoisted.resolveMarkdownTableMode.mockReset().mockReturnValue("preserve"); hoisted.convertMarkdownTables.mockReset().mockImplementation((text: string) => text); + hoisted.stripMarkdown.mockReset().mockImplementation((text: string) => text); hoisted.record.mockReset(); hoisted.normalizeIrcMessagingTarget .mockReset() @@ -85,6 +97,7 @@ afterAll(() => { vi.doUnmock("./connect-options.js"); vi.doUnmock("./protocol.js"); vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime"); + vi.doUnmock("openclaw/plugin-sdk/markdown-table-runtime"); vi.doUnmock("openclaw/plugin-sdk/text-chunking"); vi.resetModules(); }); @@ -159,6 +172,39 @@ describe("sendMessageIrc cfg threading", () => { }); }); + it("strips markdown after table conversion before sending to IRC", async () => { + const providedCfg = { + channels: { + irc: { + host: "irc.example.com", + nick: "openclaw", + }, + }, + } as unknown as CoreConfig; + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + } as unknown as IrcClient; + hoisted.resolveMarkdownTableMode.mockReturnValue("bullets"); + hoisted.convertMarkdownTables.mockReturnValue("**Status**\n- [docs](https://example.com)"); + hoisted.stripMarkdown.mockReturnValue("Status\n- docs (https://example.com)"); + + await sendMessageIrc("#room", " | a |\n| - |\n| **docs** | ", { + cfg: providedCfg, + client, + }); + + expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith( + "| a |\n| - |\n| **docs** |", + "bullets", + ); + expect(hoisted.stripMarkdown).toHaveBeenCalledWith("**Status**\n- [docs](https://example.com)"); + expect(client.sendPrivmsg).toHaveBeenCalledWith( + "#room", + "Status\n- docs (https://example.com)", + ); + }); + it("fails hard when cfg is omitted", async () => { const client = { isReady: vi.fn(() => true), @@ -254,6 +300,33 @@ describe("sendMessageIrc cfg threading", () => { }); }); + it("rejects stripped-empty replies before adding reply metadata", async () => { + const providedCfg = { + channels: { + irc: { + host: "irc.example.com", + nick: "openclaw", + }, + }, + } as unknown as CoreConfig; + const client = { + isReady: vi.fn(() => true), + sendPrivmsg: vi.fn(), + } as unknown as IrcClient; + hoisted.stripMarkdown.mockReturnValue(""); + + await expect( + sendMessageIrc("#room", "#", { + cfg: providedCfg, + client, + replyTo: "irc-parent-1", + }), + ).rejects.toThrow("Message must be non-empty for IRC sends"); + + expect(client.sendPrivmsg).not.toHaveBeenCalled(); + expect(hoisted.record).not.toHaveBeenCalled(); + }); + it("declares message adapter durable text, media, and reply with receipt proofs", async () => { const providedCfg = { channels: { diff --git a/extensions/irc/src/send.ts b/extensions/irc/src/send.ts index 2776c6d0be05..4bc2efc069b8 100644 --- a/extensions/irc/src/send.ts +++ b/extensions/irc/src/send.ts @@ -5,7 +5,7 @@ import { } from "openclaw/plugin-sdk/channel-outbound"; import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime"; import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime"; -import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking"; +import { convertMarkdownTables, stripMarkdown } from "openclaw/plugin-sdk/text-chunking"; import { resolveIrcAccount } from "./accounts.js"; import type { IrcClient } from "./client.js"; import { connectIrcClient } from "./client.js"; @@ -78,12 +78,11 @@ export async function sendMessageIrc( channel: "irc", accountId: account.accountId, }); - const prepared = convertMarkdownTables(text.trim(), tableMode); - const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared; - - if (!payload.trim()) { + const prepared = stripMarkdown(convertMarkdownTables(text.trim(), tableMode)); + if (!prepared.trim()) { throw new Error("Message must be non-empty for IRC sends"); } + const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared; const client = opts.client; if (client?.isReady()) { diff --git a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts index 92a261ca6929..4b0321cb7246 100644 --- a/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts +++ b/extensions/memory-core/src/memory/manager.fts-only-reindex.test.ts @@ -120,6 +120,8 @@ describe("memory manager FTS-only reindex", () => { ? undefined : { vector: { enabled: params.vectorEnabled } }; const cfg = { + // Provider construction is mocked here; avoid cold-loading real plugins during config resolution. + plugins: { enabled: false }, memory: { backend: "builtin", diff --git a/extensions/msteams/src/outbound.test.ts b/extensions/msteams/src/outbound.test.ts index f7765ac69e40..5c39e9242b26 100644 --- a/extensions/msteams/src/outbound.test.ts +++ b/extensions/msteams/src/outbound.test.ts @@ -297,14 +297,15 @@ describe("msteamsOutbound cfg threading", () => { const result = await requireSendPayload()({ cfg, - to: "conversation:abc", + to: "conversation:19:channel@thread.tacv2", + threadId: "presentation-thread-root", text: "Deploy finished", payload: rendered!, }); expect(mocks.sendAdaptiveCardMSTeams).toHaveBeenCalledWith({ cfg, - to: "conversation:abc", + to: "conversation:19:channel@thread.tacv2;messageid=presentation-thread-root", card: (rendered!.channelData!.msteams as { presentationCard: unknown }).presentationCard, }); expect(result).toEqual({ @@ -574,6 +575,26 @@ describe("msteamsOutbound cfg threading", () => { expect(Number.isNaN(Date.parse(pollRecord?.createdAt))).toBe(false); }); + it("forwards resolved channel thread ids to poll sends", async () => { + await requireSendPoll()({ + cfg, + to: "conversation:19:channel@thread.tacv2", + threadId: "poll-thread-root", + poll: { + question: "Ship it?", + options: ["Yes", "No"], + }, + }); + + expect(mocks.sendPollMSTeams).toHaveBeenCalledWith({ + cfg, + to: "conversation:19:channel@thread.tacv2;messageid=poll-thread-root", + question: "Ship it?", + options: ["Yes", "No"], + maxSelections: 1, + }); + }); + it("chunks outbound text without requiring MSTeams runtime initialization", () => { const chunker = msteamsOutbound.chunker; if (!chunker) { diff --git a/extensions/msteams/src/outbound.ts b/extensions/msteams/src/outbound.ts index 163226dab3a4..e23740f8adaf 100644 --- a/extensions/msteams/src/outbound.ts +++ b/extensions/msteams/src/outbound.ts @@ -218,11 +218,11 @@ export const msteamsOutbound: ChannelOutboundAdapter = { mediaReadFile, }); }, - sendPoll: async ({ cfg, to, poll }) => { + sendPoll: async ({ cfg, to, poll, threadId }) => { const maxSelections = poll.maxSelections ?? 1; const result = await sendPollMSTeams({ cfg, - to, + to: resolveMSTeamsThreadTarget(to, threadId), question: poll.question, options: poll.options, maxSelections, diff --git a/extensions/msteams/src/send-context.test.ts b/extensions/msteams/src/send-context.test.ts index 727b28efc9b8..512dc6ac74ce 100644 --- a/extensions/msteams/src/send-context.test.ts +++ b/extensions/msteams/src/send-context.test.ts @@ -50,7 +50,7 @@ function channelRef(params?: Partial): StoredConver }; } -async function resolveMSTeamsProactiveReplyStyle(params: { +async function resolveMSTeamsProactiveReplyTarget(params: { cfg?: MSTeamsConfig; conversationId: string; ref: StoredConversationReference; @@ -76,12 +76,14 @@ async function resolveMSTeamsProactiveReplyStyle(params: { }, }, } as OpenClawConfig; - return ( - await resolveMSTeamsSendContext({ - cfg, - to: `conversation:${params.conversationId}`, - }) - ).replyStyle; + const context = await resolveMSTeamsSendContext({ + cfg, + to: `conversation:${params.conversationId}`, + }); + return { + replyStyle: context.replyStyle, + threadActivityId: context.threadActivityId, + }; } beforeEach(() => { @@ -155,6 +157,7 @@ describe("resolveMSTeamsSendContext", () => { conversationId: "19:channel@thread.tacv2", ref: { threadId: "explicit-root" }, replyStyle: "thread", + threadActivityId: "explicit-root", }); expect(sendContextMockState.store.get).toHaveBeenCalledWith("19:channel@thread.tacv2"); }); @@ -186,6 +189,7 @@ describe("resolveMSTeamsSendContext", () => { conversationId: "19:channel@thread.tacv2", ref: { threadId: "graph-root" }, replyStyle: "thread", + threadActivityId: "graph-root", }); expect(sendContextMockState.store.get).toHaveBeenCalledWith("19:channel@thread.tacv2"); }); @@ -248,27 +252,27 @@ describe("resolveMSTeamsSendContext", () => { }); }); -describe("resolveMSTeamsProactiveReplyStyle", () => { +describe("resolveMSTeamsProactiveReplyTarget", () => { it("uses thread for channel conversations with a stored thread root", async () => { await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: {}, conversationId: "19:channel@thread.tacv2", ref: channelRef({ threadId: "thread-root-1" }), conversationType: "channel", }), - ).resolves.toBe("thread"); + ).resolves.toEqual({ replyStyle: "thread", threadActivityId: "thread-root-1" }); }); it("falls back to activityId for legacy channel references", async () => { await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: {}, conversationId: "19:channel@thread.tacv2", ref: channelRef({ activityId: "legacy-root-1" }), conversationType: "channel", }), - ).resolves.toBe("thread"); + ).resolves.toEqual({ replyStyle: "thread", threadActivityId: "legacy-root-1" }); }); it("keeps configured top-level channel routing", async () => { @@ -284,44 +288,44 @@ describe("resolveMSTeamsProactiveReplyStyle", () => { }; await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg, conversationId: "19:channel@thread.tacv2", ref: channelRef({ threadId: "thread-root-1" }), conversationType: "channel", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); }); it("uses top-level when a channel has no stored thread root", async () => { await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: { replyStyle: "thread" }, conversationId: "19:channel@thread.tacv2", ref: channelRef(), conversationType: "channel", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); }); it("uses top-level for non-channel conversations", async () => { const ref = channelRef({ activityId: "activity-1" }); await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: { replyStyle: "thread" }, conversationId: "19:group@thread.v2", ref, conversationType: "groupChat", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); await expect( - resolveMSTeamsProactiveReplyStyle({ + resolveMSTeamsProactiveReplyTarget({ cfg: { replyStyle: "thread" }, conversationId: "a:personal", ref, conversationType: "personal", }), - ).resolves.toBe("top-level"); + ).resolves.toEqual({ replyStyle: "top-level", threadActivityId: undefined }); }); }); diff --git a/extensions/msteams/src/send-context.ts b/extensions/msteams/src/send-context.ts index 1f60a705e402..b3b8c6c7fbff 100644 --- a/extensions/msteams/src/send-context.ts +++ b/extensions/msteams/src/send-context.ts @@ -3,7 +3,6 @@ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coer import { resolveChannelMediaMaxBytes, type MSTeamsConfig, - type MSTeamsReplyStyle, type OpenClawConfig, type PluginRuntime, } from "../runtime-api.js"; @@ -33,6 +32,12 @@ import { resolveMSTeamsCredentials } from "./token.js"; type MSTeamsConversationType = "personal" | "groupChat" | "channel"; +// Keep reply policy and the Connector thread suffix together so every proactive +// activity kind uses the same resolved destination instead of re-deriving it. +type MSTeamsProactiveReplyTarget = + | { replyStyle: "thread"; threadActivityId: string } + | { replyStyle: "top-level"; threadActivityId?: never }; + export type MSTeamsProactiveContext = { appId: string; conversationId: string; @@ -41,8 +46,6 @@ export type MSTeamsProactiveContext = { log: ReturnType; /** The type of conversation: personal (1:1), groupChat, or channel */ conversationType: MSTeamsConversationType; - /** Reply style resolved for proactive text/media sends. */ - replyStyle: MSTeamsReplyStyle; /** Teams SDK cloud/service endpoint used to validate proactive sends. */ sdkCloudOptions: MSTeamsSdkCloudOptions; /** Token provider for Graph API / SharePoint operations */ @@ -51,17 +54,17 @@ export type MSTeamsProactiveContext = { sharePointSiteId?: string; /** Resolved media max bytes from config (default: 100MB) */ mediaMaxBytes?: number; -}; +} & MSTeamsProactiveReplyTarget; -function resolveMSTeamsProactiveReplyStyle(params: { +function resolveMSTeamsProactiveReplyTarget(params: { cfg?: MSTeamsConfig; conversationId: string; ref: StoredConversationReference; conversationType: MSTeamsConversationType; -}): MSTeamsReplyStyle { +}): MSTeamsProactiveReplyTarget { const threadRootId = params.ref.threadId ?? params.ref.activityId; if (params.conversationType !== "channel" || !threadRootId) { - return "top-level"; + return { replyStyle: "top-level" }; } const routeConfig = resolveMSTeamsRouteConfig({ @@ -76,7 +79,7 @@ function resolveMSTeamsProactiveReplyStyle(params: { teamConfig: routeConfig.teamConfig, channelConfig: routeConfig.channelConfig, }); - return replyStyle; + return replyStyle === "thread" ? { replyStyle, threadActivityId: threadRootId } : { replyStyle }; } /** @@ -250,10 +253,10 @@ export async function resolveMSTeamsSendContext(params: { // An explicit messageid is a caller-owned destination. Ambient and stored // roots still obey route policy, but explicit channel roots must not be // flattened by a top-level default. - const replyStyle = + const replyTarget: MSTeamsProactiveReplyTarget = recipient.threadId && conversationType === "channel" - ? "thread" - : resolveMSTeamsProactiveReplyStyle({ + ? { replyStyle: "thread", threadActivityId: recipient.threadId } + : resolveMSTeamsProactiveReplyTarget({ cfg: msteamsCfg, conversationId, ref: safeRef, @@ -276,7 +279,7 @@ export async function resolveMSTeamsSendContext(params: { app, log, conversationType, - replyStyle, + ...replyTarget, sdkCloudOptions, tokenProvider, sharePointSiteId, diff --git a/extensions/msteams/src/send.threaded-attachments.test.ts b/extensions/msteams/src/send.threaded-attachments.test.ts index 42e24c5d746c..7d569635e156 100644 --- a/extensions/msteams/src/send.threaded-attachments.test.ts +++ b/extensions/msteams/src/send.threaded-attachments.test.ts @@ -4,7 +4,7 @@ import type { AddressInfo } from "node:net"; import { Client as TeamsApiClient } from "@microsoft/teams.api"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../runtime-api.js"; -import { sendMessageMSTeams } from "./send.js"; +import { sendAdaptiveCardMSTeams, sendMessageMSTeams, sendPollMSTeams } from "./send.js"; const serviceUrl = "https://smba.trafficmanager.net/amer"; const conversationId = "19:channel@thread.tacv2"; @@ -173,6 +173,15 @@ type AttachmentRoutingCase = { expectedConversationId: string; }; +type StructuredRoutingCase = { + label: string; + conversationType: "channel" | "groupChat" | "personal"; + replyStyle: "thread" | "top-level"; + threadActivityId?: string; + storedThreadId?: string; + expectedConversationId: string; +}; + const attachmentRoutingCases: AttachmentRoutingCase[] = [ { label: "channel attachment in its stored thread root", @@ -205,6 +214,69 @@ const attachmentRoutingCases: AttachmentRoutingCase[] = [ }, ]; +const structuredRoutingCases: StructuredRoutingCase[] = [ + { + label: "threaded channel", + conversationType: "channel", + replyStyle: "thread", + threadActivityId: "thread-root-1", + storedThreadId: "thread-root-1", + expectedConversationId: `${conversationId};messageid=thread-root-1`, + }, + { + label: "top-level channel", + conversationType: "channel", + replyStyle: "top-level", + storedThreadId: "thread-root-1", + expectedConversationId: conversationId, + }, + { + label: "group chat", + conversationType: "groupChat", + replyStyle: "top-level", + storedThreadId: "group-activity-1", + expectedConversationId: conversationId, + }, + { + label: "personal chat", + conversationType: "personal", + replyStyle: "top-level", + storedThreadId: "personal-activity-1", + expectedConversationId: conversationId, + }, +]; + +type StructuredSender = { + label: string; + send: (cfg: OpenClawConfig) => Promise; +}; + +const structuredSenders: StructuredSender[] = [ + { + label: "presentation card", + send: async (cfg) => + await sendAdaptiveCardMSTeams({ + cfg, + to: conversationId, + card: { + type: "AdaptiveCard", + version: "1.4", + body: [{ type: "TextBlock", text: "Deploy finished" }], + }, + }), + }, + { + label: "poll", + send: async (cfg) => + await sendPollMSTeams({ + cfg, + to: conversationId, + question: "Ship it?", + options: ["Yes", "No"], + }), + }, +]; + describe("Microsoft Teams SharePoint attachment thread routing", () => { beforeEach(() => { vi.clearAllMocks(); @@ -231,6 +303,9 @@ describe("Microsoft Teams SharePoint attachment thread routing", () => { }, conversationType, replyStyle, + ...(replyStyle === "thread" && conversationType === "channel" + ? { threadActivityId: threadId ?? activityId } + : {}), sdkCloudOptions: { cloud: "Public" }, tokenProvider: { getAccessToken: vi.fn(async () => "token") }, sharePointSiteId: "sharepoint-site-1", @@ -307,3 +382,54 @@ describe("Microsoft Teams SharePoint attachment thread routing", () => { }); }); }); + +describe.each(structuredSenders)("Microsoft Teams $label thread routing", ({ send }) => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it.each(structuredRoutingCases)( + "sends to the resolved $label identity through the real Teams SDK", + async ({ + conversationType, + replyStyle, + threadActivityId, + storedThreadId, + expectedConversationId, + }) => { + await withRealTeamsSdkHttp(async ({ api, requests }) => { + mockState.resolveMSTeamsSendContext.mockResolvedValue({ + app: { api }, + appId: "app-id", + conversationId, + ref: { + serviceUrl, + agent: { id: "28:bot", name: "OpenClaw", role: "bot" }, + user: { id: "29:user" }, + conversation: { id: conversationId, conversationType }, + activityId: "incoming-activity-1", + ...(storedThreadId ? { threadId: storedThreadId } : {}), + }, + conversationType, + replyStyle, + ...(threadActivityId ? { threadActivityId } : {}), + sdkCloudOptions: { cloud: "Public" }, + tokenProvider: { getAccessToken: vi.fn(async () => "token") }, + log: { debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }, + }); + + await send({} as OpenClawConfig); + + expect(requests).toHaveLength(1); + expect(requests[0]).toMatchObject({ + path: `${new URL(serviceUrl).pathname}/v3/conversations/${expectedConversationId}/activities`, + body: { + type: "message", + conversation: { id: expectedConversationId, conversationType }, + attachments: [{ contentType: "application/vnd.microsoft.card.adaptive" }], + }, + }); + }); + }, + ); +}); diff --git a/extensions/msteams/src/send.ts b/extensions/msteams/src/send.ts index d65373328c79..0d3ef3896bb8 100644 --- a/extensions/msteams/src/send.ts +++ b/extensions/msteams/src/send.ts @@ -182,17 +182,7 @@ export async function sendMessageMSTeams( }); const messageText = formatMSTeamsMarkdown(text ?? "", tableMode); const ctx = await resolveMSTeamsSendContext({ cfg, to }); - const { - app, - conversationId, - ref, - log, - conversationType, - replyStyle, - tokenProvider, - sharePointSiteId, - sdkCloudOptions, - } = ctx; + const { conversationId, log, conversationType, tokenProvider, sharePointSiteId } = ctx; log.debug?.("sending proactive message", { conversationId, @@ -245,11 +235,9 @@ export async function sendMessageMSTeams( log.debug?.("sending file consent card", { uploadId, fileName, size: media.buffer.length }); const messageId = await sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix: "msteams consent card send", - serviceUrlBoundary: sdkCloudOptions, }); // Store the activity ID so the accept handler can replace the consent @@ -326,15 +314,8 @@ export async function sendMessageMSTeams( attachments: [fileCardAttachment], }; const messageId = await sendProactiveActivityRaw({ - app, - ref, + ctx, activity, - // Only channel replies carry a thread root; top-level and group sends must stay unchanged. - threadActivityId: - replyStyle === "thread" && conversationType === "channel" - ? (ref.threadId ?? ref.activityId) - : undefined, - serviceUrlBoundary: sdkCloudOptions, }); log.info("sent native file card", { @@ -428,41 +409,32 @@ async function sendTextWithMedia( } type ProactiveActivityParams = { - app: MSTeamsProactiveContext["app"]; - ref: MSTeamsProactiveContext["ref"]; + ctx: MSTeamsProactiveContext; activity: Record; errorPrefix: string; - serviceUrlBoundary: MSTeamsProactiveContext["sdkCloudOptions"]; }; -type ProactiveActivityRawParams = Omit & { - threadActivityId?: string; -}; +type ProactiveActivityRawParams = Omit; async function sendProactiveActivityRaw({ - app, - ref, + ctx, activity, - threadActivityId, - serviceUrlBoundary, }: ProactiveActivityRawParams): Promise { - const baseRef = buildConversationReference(ref); - const response = await sendMSTeamsActivityWithReference(app, baseRef, activity, { - ...(threadActivityId ? { threadActivityId } : {}), - serviceUrlBoundary, + const baseRef = buildConversationReference(ctx.ref); + const response = await sendMSTeamsActivityWithReference(ctx.app, baseRef, activity, { + ...(ctx.threadActivityId ? { threadActivityId: ctx.threadActivityId } : {}), + serviceUrlBoundary: ctx.sdkCloudOptions, }); return extractMessageId(response) ?? "unknown"; } async function sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix, - serviceUrlBoundary, }: ProactiveActivityParams): Promise { try { - return await sendProactiveActivityRaw({ app, ref, activity, serviceUrlBoundary }); + return await sendProactiveActivityRaw({ ctx, activity }); } catch (err) { const classification = classifyMSTeamsSendError(err); const hint = formatMSTeamsSendErrorHint(classification); @@ -481,10 +453,11 @@ export async function sendPollMSTeams( params: SendMSTeamsPollParams, ): Promise { const { cfg, to, question, options, maxSelections } = params; - const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({ + const ctx = await resolveMSTeamsSendContext({ cfg, to, }); + const { conversationId, log } = ctx; const pollCard = buildMSTeamsPollCard({ question, @@ -510,11 +483,9 @@ export async function sendPollMSTeams( // Send poll via proactive conversation (Adaptive Cards require direct activity send) const messageId = await sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix: "msteams poll send", - serviceUrlBoundary: sdkCloudOptions, }); log.info("sent poll", { conversationId, pollId: pollCard.pollId, messageId }); @@ -533,10 +504,11 @@ export async function sendAdaptiveCardMSTeams( params: SendMSTeamsCardParams, ): Promise { const { cfg, to, card } = params; - const { app, conversationId, ref, log, sdkCloudOptions } = await resolveMSTeamsSendContext({ + const ctx = await resolveMSTeamsSendContext({ cfg, to, }); + const { conversationId, log } = ctx; log.debug?.("sending adaptive card", { conversationId, @@ -556,11 +528,9 @@ export async function sendAdaptiveCardMSTeams( // Send card via proactive conversation const messageId = await sendProactiveActivity({ - app, - ref, + ctx, activity, errorPrefix: "msteams card send", - serviceUrlBoundary: sdkCloudOptions, }); log.info("sent adaptive card", { conversationId, messageId }); diff --git a/extensions/openai/memory-embedding-adapter.test.ts b/extensions/openai/memory-embedding-adapter.test.ts index 2e0f40c8896e..2442e2b32149 100644 --- a/extensions/openai/memory-embedding-adapter.test.ts +++ b/extensions/openai/memory-embedding-adapter.test.ts @@ -1,6 +1,10 @@ // Openai tests cover memory embedding adapter plugin behavior. -import type { MemoryEmbeddingProvider } from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + resolveRemoteEmbeddingBearerClient, + type MemoryEmbeddingProvider, +} from "openclaw/plugin-sdk/memory-core-host-engine-embeddings"; +import { hashText } from "openclaw/plugin-sdk/memory-core-host-engine-storage"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; const mocks = vi.hoisted(() => ({ createOpenAiEmbeddingProvider: vi.fn(), @@ -27,6 +31,10 @@ const provider: MemoryEmbeddingProvider = { }; describe("OpenAI memory embedding adapter", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + beforeEach(() => { mocks.createOpenAiEmbeddingProvider.mockReset(); mocks.runOpenAiEmbeddingBatches.mockClear(); @@ -43,6 +51,105 @@ describe("OpenAI memory embedding adapter", () => { }); }); + it("keeps native OpenAI embedding cache identity stable across OpenClaw versions", async () => { + const createForVersion = async (version: string) => { + vi.stubEnv("OPENCLAW_VERSION", version); + const client = await resolveRemoteEmbeddingBearerClient({ + provider: "openai", + defaultBaseUrl: "https://api.openai.com/v1", + options: { + config: { models: {} } as never, + model: "text-embedding-3-small", + remote: { apiKey: "fixture-secret" }, + }, + }); + mocks.createOpenAiEmbeddingProvider.mockResolvedValueOnce({ + provider, + client: { ...client, model: "text-embedding-3-small" }, + }); + const result = await openAiMemoryEmbeddingProviderAdapter.create({ + config: {} as never, + provider: "openai", + model: "text-embedding-3-small", + fallback: "none", + }); + return { headers: client.headers, cacheKeyData: result.runtime?.cacheKeyData }; + }; + + const previous = await createForVersion("2026.7.1"); + const current = await createForVersion("2026.7.2"); + + expect(previous.headers).toMatchObject({ + Authorization: "Bearer fixture-secret", + version: "2026.7.1", + "User-Agent": "openclaw/2026.7.1", + }); + expect(current.headers).toMatchObject({ + Authorization: "Bearer fixture-secret", + version: "2026.7.2", + "User-Agent": "openclaw/2026.7.2", + }); + expect(current.cacheKeyData).toEqual(previous.cacheKeyData); + expect(hashText(JSON.stringify(current.cacheKeyData))).toBe( + hashText(JSON.stringify(previous.cacheKeyData)), + ); + expect(current.cacheKeyData).toMatchObject({ + provider: "openai", + baseUrl: "https://api.openai.com/v1", + model: "text-embedding-3-small", + headers: [ + ["Content-Type", "application/json"], + ["originator", "openclaw"], + ], + }); + expect(JSON.stringify(current.cacheKeyData)).not.toContain("fixture-secret"); + }); + + it("preserves custom endpoint tenant and version-like cache identity headers", async () => { + const createForTenant = async (tenant: string) => { + const client = await resolveRemoteEmbeddingBearerClient({ + provider: "bailian-embedding", + defaultBaseUrl: "https://embeddings.example/v1", + options: { + config: { models: {} } as never, + model: "text-embedding-v3", + remote: { + apiKey: "fixture-secret", + headers: { + "X-Tenant": tenant, + version: "tenant-api-v2", + "User-Agent": "tenant-client/2", + }, + }, + }, + }); + mocks.createOpenAiEmbeddingProvider.mockResolvedValueOnce({ + provider, + client: { ...client, model: "text-embedding-v3" }, + }); + return await openAiMemoryEmbeddingProviderAdapter.create({ + config: {} as never, + provider: "bailian-embedding", + model: "text-embedding-v3", + fallback: "none", + }); + }; + + const first = await createForTenant("tenant-a"); + const second = await createForTenant("tenant-b"); + const headers = first.runtime?.cacheKeyData?.headers; + + expect(headers).toEqual( + expect.arrayContaining([ + ["X-Tenant", "tenant-a"], + ["version", "tenant-api-v2"], + ["User-Agent", "tenant-client/2"], + ]), + ); + expect(first.runtime?.cacheKeyData).not.toEqual(second.runtime?.cacheKeyData); + expect(JSON.stringify(first.runtime?.cacheKeyData)).not.toContain("fixture-secret"); + }); + it("sends document input_type in OpenAI batch embedding requests", async () => { const result = await openAiMemoryEmbeddingProviderAdapter.create({ config: {} as never, diff --git a/extensions/openai/memory-embedding-adapter.ts b/extensions/openai/memory-embedding-adapter.ts index 43b41e8fccf8..7046092bcebb 100644 --- a/extensions/openai/memory-embedding-adapter.ts +++ b/extensions/openai/memory-embedding-adapter.ts @@ -11,6 +11,23 @@ import { DEFAULT_OPENAI_EMBEDDING_MODEL, } from "./embedding-provider.js"; +function resolveEmbeddingCacheExcludedHeaders(providerId: string, baseUrl: string): string[] { + const excludedHeaders = ["authorization"]; + if (providerId !== "openai") { + return excludedHeaders; + } + try { + if (new URL(baseUrl).hostname.toLowerCase().replace(/\.+$/, "") === "api.openai.com") { + // Native attribution changes on every upgrade; cache identity must describe embeddings, + // not the OpenClaw build that requested them. + excludedHeaders.push("version", "user-agent"); + } + } catch { + // Invalid URLs are handled by the embedding client; keep existing custom-header identity. + } + return excludedHeaders; +} + export const openAiMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapter = { id: "openai", defaultModel: DEFAULT_OPENAI_EMBEDDING_MODEL, @@ -37,7 +54,10 @@ export const openAiMemoryEmbeddingProviderAdapter: MemoryEmbeddingProviderAdapte model: client.model, outputDimensionality: client.outputDimensionality, documentInputType: client.documentInputType ?? client.inputType, - headers: sanitizeEmbeddingCacheHeaders(client.headers, ["authorization"]), + headers: sanitizeEmbeddingCacheHeaders( + client.headers, + resolveEmbeddingCacheExcludedHeaders(resolvedProvider, client.baseUrl), + ), }, batchEmbed: async (batch) => { const inputType = client.documentInputType ?? client.inputType; diff --git a/extensions/openai/realtime-audio-buffer-ownership.test.ts b/extensions/openai/realtime-audio-buffer-ownership.test.ts new file mode 100644 index 000000000000..dd26c050f524 --- /dev/null +++ b/extensions/openai/realtime-audio-buffer-ownership.test.ts @@ -0,0 +1,132 @@ +import { once } from "node:events"; +import type { RealtimeVoiceBridge } from "openclaw/plugin-sdk/realtime-voice"; +import { describe, expect, it, vi } from "vitest"; +import WebSocket, { type RawData, WebSocketServer } from "ws"; +import { OpenAIQuicksilverVoiceBridge } from "./realtime-quicksilver-bridge.js"; +import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; + +type RealtimeProviderKind = "native" | "gpt-live"; + +function parseWebSocketMessage(data: RawData): Record { + const bytes = Buffer.isBuffer(data) + ? data + : Array.isArray(data) + ? Buffer.concat(data) + : Buffer.from(data); + return JSON.parse(bytes.toString("utf8")) as Record; +} + +async function withRealtimeProvider( + kind: RealtimeProviderKind, + prepareAudio: (bridge: RealtimeVoiceBridge) => void, +): Promise>> { + const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append"; + const server = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(server, "listening"); + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("expected an available local realtime WebSocket address"); + } + const received: Array> = []; + server.once("connection", (socket) => { + socket.on("message", (payload) => { + const event = parseWebSocketMessage(payload); + received.push(event); + if (event.type === "session.update") { + socket.send( + JSON.stringify( + kind === "native" + ? { type: "session.updated" } + : { + type: "session.started", + session: { id: "fixture-live", expires_at: Math.floor(Date.now() / 1000) + 60 }, + }, + ), + ); + } + }); + }); + + const endpoint = `http://127.0.0.1:${address.port}`; + const bridge = + kind === "native" + ? buildOpenAIRealtimeVoiceProvider().createBridge({ + providerConfig: { + apiKey: "fixture-local", // pragma: allowlist secret + azureEndpoint: endpoint, + azureDeployment: "fixture-realtime", + }, + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }) + : new OpenAIQuicksilverVoiceBridge({ + providerConfig: {}, + model: "gpt-live-1-codex", + audioFormat: { encoding: "pcm16", sampleRateHz: 24000, channels: 1 }, + resolveAuth: async () => ({ type: "api-key", token: "fixture-local" }), + webSocketFactory: (_url, options) => new WebSocket(endpoint, options), + onAudio: vi.fn(), + onClearAudio: vi.fn(), + }); + + try { + prepareAudio(bridge); + await bridge.connect(); + await vi.waitFor(() => { + expect(received.some((event) => event.type === audioEventType)).toBe(true); + }); + return received; + } finally { + bridge.close(); + for (const client of server.clients) { + client.terminate(); + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + } +} + +describe("OpenAI realtime queued audio buffer ownership", () => { + it.each(["native", "gpt-live"])( + "%s preserves each reusable producer frame until the real WebSocket is ready", + async (kind) => { + const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append"; + const received = await withRealtimeProvider(kind, (bridge) => { + const producerAllocation = Buffer.alloc(2 * 1024 * 1024, 0x7f); + const producerView = producerAllocation.subarray(0, 1); + bridge.sendAudio(producerView); + producerAllocation[0] = 0x41; + bridge.sendAudio(producerView); + producerAllocation[0] = 0; + }); + + expect(received.filter((event) => event.type === audioEventType)).toEqual([ + { type: audioEventType, audio: "fw==" }, + { type: audioEventType, audio: "QQ==" }, + ]); + }, + ); + + it.each(["native", "gpt-live"])( + "%s rejects oversized producer frames before allocating a queued copy", + async (kind) => { + const audioEventType = kind === "native" ? "input_audio_buffer.append" : "input_audio.append"; + const received = await withRealtimeProvider(kind, (bridge) => { + const oversized = Buffer.alloc(1024 * 1024 + 1); + const copyBuffer = vi.spyOn(Buffer, "from"); + try { + bridge.sendAudio(oversized); + expect(copyBuffer).not.toHaveBeenCalled(); + } finally { + copyBuffer.mockRestore(); + } + bridge.sendAudio(Buffer.from([0x7f])); + }); + + expect(received.filter((event) => event.type === audioEventType)).toEqual([ + { type: audioEventType, audio: "fw==" }, + ]); + }, + ); +}); diff --git a/extensions/openai/realtime-quicksilver-bridge.ts b/extensions/openai/realtime-quicksilver-bridge.ts index de8583cfcca1..6d43551f56df 100644 --- a/extensions/openai/realtime-quicksilver-bridge.ts +++ b/extensions/openai/realtime-quicksilver-bridge.ts @@ -568,8 +568,10 @@ export class OpenAIQuicksilverVoiceBridge implements RealtimeVoiceBridge { ) { return; } - this.pendingAudio.push(audio); - this.pendingAudioBytes += audio.byteLength; + // Capture transports can recycle caller-owned views before the provider becomes ready. + const queuedAudio = Buffer.from(audio); + this.pendingAudio.push(queuedAudio); + this.pendingAudioBytes += queuedAudio.byteLength; } private resetTerminalState(): void { diff --git a/extensions/openai/realtime-voice-provider.test.ts b/extensions/openai/realtime-voice-provider.test.ts index 151cc2f6801a..51705a4088ea 100644 --- a/extensions/openai/realtime-voice-provider.test.ts +++ b/extensions/openai/realtime-voice-provider.test.ts @@ -1,6 +1,10 @@ // Openai tests cover realtime voice provider plugin behavior. import { REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ } from "openclaw/plugin-sdk/realtime-voice"; -import type { RealtimeVoiceBridge, RealtimeVoiceTool } from "openclaw/plugin-sdk/realtime-voice"; +import type { + RealtimeVoiceBridge, + RealtimeVoiceBridgeCreateRequest, + RealtimeVoiceTool, +} from "openclaw/plugin-sdk/realtime-voice"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { buildOpenAIRealtimeVoiceProvider } from "./realtime-voice-provider.js"; @@ -192,6 +196,57 @@ function parseSent(socket: FakeWebSocketInstance): SentRealtimeEvent[] { return socket.sent.map((payload: string) => JSON.parse(payload) as SentRealtimeEvent); } +function createNativeBridge( + overrides: Partial = {}, +): RealtimeVoiceBridge { + return buildOpenAIRealtimeVoiceProvider().createBridge({ + providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + onAudio: vi.fn(), + onClearAudio: vi.fn(), + ...overrides, + }); +} + +function requireSocket(index = 0): FakeWebSocketInstance { + const socket = FakeWebSocket.instances[index]; + if (!socket) { + throw new Error("expected bridge to create a websocket"); + } + return socket; +} + +function beginBridgeConnection( + bridge: RealtimeVoiceBridge, + socketIndex = 0, +): { connecting: Promise; socket: FakeWebSocketInstance } { + const connecting = bridge.connect(); + return { connecting, socket: requireSocket(socketIndex) }; +} + +function openSocket(socket: FakeWebSocketInstance): void { + socket.readyState = FakeWebSocket.OPEN; + socket.emit("open"); +} + +function emitServerEvent(socket: FakeWebSocketInstance, event: Record): void { + socket.emit("message", Buffer.from(JSON.stringify(event))); +} + +function emitSessionUpdated(socket: FakeWebSocketInstance): void { + emitServerEvent(socket, { type: "session.updated" }); +} + +async function connectReadyBridge( + bridge: RealtimeVoiceBridge, + socketIndex = 0, +): Promise { + const { connecting, socket } = beginBridgeConnection(bridge, socketIndex); + openSocket(socket); + emitSessionUpdated(socket); + await connecting; + return socket; +} + function expectedResponseCreateEvent() { return expect.objectContaining({ type: "response.create", @@ -1458,32 +1513,23 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("waits for session.updated before draining audio and firing onReady", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onReady = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ instructions: "Be helpful.", language: "de", - onAudio: vi.fn(), - onClearAudio: vi.fn(), onReady, }); - const connecting = bridge.connect(); + const { connecting, socket } = beginBridgeConnection(bridge); let connectResolved = false; void connecting.then(() => { connectResolved = true; }); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); await Promise.resolve(); bridge.sendAudio(Buffer.from("before-ready")); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.created" }))); + emitServerEvent(socket, { type: "session.created" }); expect(connectResolved).toBe(false); expect(onReady).not.toHaveBeenCalled(); @@ -1507,7 +1553,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(session).not.toHaveProperty("temperature"); expect(bridge.isConnected()).toBe(false); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; expect(connectResolved).toBe(true); @@ -1520,25 +1566,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("bounds queued audio by aggregate bytes before session readiness", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); + openSocket(socket); await Promise.resolve(); bridge.sendAudio(Buffer.alloc(512 * 1024, 0x01)); bridge.sendAudio(Buffer.alloc(512 * 1024, 0x02)); bridge.sendAudio(Buffer.from("overflow")); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; const audioEvents = parseSent(socket).filter( @@ -1552,14 +1588,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("discards audio closed before the first connection and reconnects fresh", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - }); + const bridge = createNativeBridge({ onClose }); bridge.sendAudio(Buffer.from("queued-before-connect")); bridge.close(); @@ -1569,14 +1599,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(FakeWebSocket.instances).toHaveLength(0); expect(onClose).not.toHaveBeenCalled(); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to connect"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const { connecting, socket } = beginBridgeConnection(bridge); + openSocket(socket); + emitSessionUpdated(socket); await connecting; expect( @@ -1589,19 +1614,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not carry queued audio across terminal close and explicit reconnect", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const firstConnect = bridge.connect(); - const firstSocket = FakeWebSocket.instances[0]; - if (!firstSocket) { - throw new Error("expected bridge to create a websocket"); - } - firstSocket.readyState = FakeWebSocket.OPEN; - firstSocket.emit("open"); + const bridge = createNativeBridge(); + const { connecting: firstConnect, socket: firstSocket } = beginBridgeConnection(bridge); + openSocket(firstSocket); await Promise.resolve(); bridge.sendAudio(Buffer.from("queued-before-close")); @@ -1609,14 +1624,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { await firstConnect; bridge.sendAudio(Buffer.from("sent-after-close")); - const reconnecting = bridge.connect(); - const secondSocket = FakeWebSocket.instances[1]; - if (!secondSocket) { - throw new Error("expected bridge to reconnect"); - } - secondSocket.readyState = FakeWebSocket.OPEN; - secondSocket.emit("open"); - secondSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const { connecting: reconnecting, socket: secondSocket } = beginBridgeConnection(bridge, 1); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); await reconnecting; expect( @@ -1626,25 +1636,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("shares an in-flight connection until session readiness", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onReady = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onReady, - }); + const bridge = createNativeBridge({ onReady }); const firstConnect = bridge.connect(); const secondConnect = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const socket = requireSocket(); expect(FakeWebSocket.instances).toHaveLength(1); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(socket); + emitSessionUpdated(socket); await Promise.all([firstConnect, secondConnect]); expect(onReady).toHaveBeenCalledOnce(); @@ -1652,31 +1652,22 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("suppresses auto responses before draining queued initial greeting audio", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const bridgeRef: { current?: RealtimeVoiceBridge } = {}; const onReady = vi.fn(() => { bridgeRef.current?.triggerGreeting?.("Say exactly: hello from explicit speech."); }); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ instructions: "Be helpful.", - onAudio: vi.fn(), - onClearAudio: vi.fn(), onReady, }); bridgeRef.current = bridge; - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); await Promise.resolve(); bridge.sendAudio(Buffer.from("before-ready")); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; const sent = parseSent(socket); @@ -1712,7 +1703,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(sent.filter((event) => event.type === "response.create")).toHaveLength(1); expect(onReady).toHaveBeenCalledTimes(1); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), @@ -1725,9 +1716,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("omits unsupported OpenAI tool names from GA session updates", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ tools: [ createRealtimeTool("1_lookup"), createRealtimeTool("calendar.lookup:next"), @@ -1737,45 +1726,26 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { createMalformedToolName(42), createUnreadableToolName(), ], - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); const tools = requireSession(socket).tools as Array<{ name?: string }>; expect(tools.map((tool) => tool.name)).toEqual(["1_lookup", "x".repeat(65)]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; }); it("rotates realtime bridges on provider max-duration events without reporting an error", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - onEvent, - }); - const connecting = bridge.connect(); - const firstSocket = FakeWebSocket.instances[0]; - if (!firstSocket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onError, onEvent }); + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - firstSocket.readyState = FakeWebSocket.OPEN; - firstSocket.emit("open"); - firstSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(firstSocket); + emitSessionUpdated(firstSocket); await connecting; firstSocket.emit( @@ -1803,13 +1773,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { await vi.advanceTimersByTimeAsync(1000); await vi.waitFor(() => expect(FakeWebSocket.instances).toHaveLength(2)); - const secondSocket = FakeWebSocket.instances[1]; - if (!secondSocket) { - throw new Error("expected bridge to reconnect"); - } - secondSocket.readyState = FakeWebSocket.OPEN; - secondSocket.emit("open"); - secondSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const secondSocket = requireSocket(1); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); await vi.waitFor(() => expect(onEvent).toHaveBeenCalledWith({ @@ -1832,23 +1798,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("cancels a pending reconnect and allows a later explicit connect", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onError }); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(socket); + emitSessionUpdated(socket); await connecting; socket.readyState = FakeWebSocket.CLOSED; @@ -1863,14 +1818,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(FakeWebSocket.instances).toHaveLength(1); expect(onError).not.toHaveBeenCalled(); - const reconnecting = bridge.connect(); - const reconnectedSocket = FakeWebSocket.instances[1]; - if (!reconnectedSocket) { - throw new Error("expected bridge to reconnect after close"); - } - reconnectedSocket.readyState = FakeWebSocket.OPEN; - reconnectedSocket.emit("open"); - reconnectedSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const { connecting: reconnecting, socket: reconnectedSocket } = beginBridgeConnection( + bridge, + 1, + ); + openSocket(reconnectedSocket); + emitSessionUpdated(reconnectedSocket); await reconnecting; expect(bridge.isConnected()).toBe(true); @@ -1881,26 +1834,18 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("ignores late events from a socket replaced by reconnect", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onClose = vi.fn(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, - onClearAudio: vi.fn(), onClose, onError, }); - const connecting = bridge.connect(); - const firstSocket = FakeWebSocket.instances[0]; - if (!firstSocket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - firstSocket.readyState = FakeWebSocket.OPEN; - firstSocket.emit("open"); - firstSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(firstSocket); + emitSessionUpdated(firstSocket); await connecting; firstSocket.readyState = FakeWebSocket.CLOSED; @@ -1919,16 +1864,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(onError).not.toHaveBeenCalled(); await vi.advanceTimersByTimeAsync(1000); - const secondSocket = FakeWebSocket.instances[1]; - if (!secondSocket) { - throw new Error("expected bridge to reconnect"); - } - secondSocket.readyState = FakeWebSocket.OPEN; - secondSocket.emit("open"); - secondSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const secondSocket = requireSocket(1); + openSocket(secondSocket); + emitSessionUpdated(secondSocket); await vi.waitFor(() => expect(bridge.isConnected()).toBe(true)); - firstSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(firstSocket); firstSocket.emit("error", new Error("late socket failure")); firstSocket.emit("close", 1006, Buffer.from("late socket close")); await vi.advanceTimersByTimeAsync(0); @@ -1943,27 +1884,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("exhausts retries when sockets open but never become provider-ready", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); const onError = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - onError, - onEvent, - }); - const connecting = bridge.connect(); - const firstSocket = FakeWebSocket.instances[0]; - if (!firstSocket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onClose, onError, onEvent }); + const { connecting, socket: firstSocket } = beginBridgeConnection(bridge); - firstSocket.readyState = FakeWebSocket.OPEN; - firstSocket.emit("open"); - firstSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(firstSocket); + emitSessionUpdated(firstSocket); await connecting; firstSocket.readyState = FakeWebSocket.CLOSED; @@ -1978,12 +1906,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }), ); await vi.advanceTimersByTimeAsync(1000 * 2 ** (attempt - 1)); - const retrySocket = FakeWebSocket.instances[attempt]; - if (!retrySocket) { - throw new Error(`expected reconnect socket ${attempt}`); - } - retrySocket.readyState = FakeWebSocket.OPEN; - retrySocket.emit("open"); + const retrySocket = requireSocket(attempt); + openSocket(retrySocket); retrySocket.emit( "message", Buffer.from( @@ -2010,8 +1934,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("keeps Azure deployment bridges on deployment-compatible session payloads", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ + const bridge = createNativeBridge({ providerConfig: { apiKey: "sk-test", // pragma: allowlist secret azureEndpoint: "https://example.openai.azure.com/", @@ -2026,21 +1949,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { createRealtimeTool("calendar.lookup:next"), createRealtimeTool("x".repeat(65)), ], - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); expect(socket.args[0]).toBe( "wss://example.openai.azure.com/openai/realtime?api-version=2024-10-01-preview&deployment=realtime-prod", ); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); await Promise.resolve(); const session = requireSession(socket); @@ -2065,7 +1981,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { const tools = session.tools as Array<{ name?: string }>; expect(tools.map((tool) => tool.name)).toEqual(["1_lookup"]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + emitSessionUpdated(socket); await connecting; bridge.triggerGreeting?.("Say hello."); @@ -2085,7 +2001,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expectedResponseCreateEvent(), ]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(parseSent(socket).at(-1)).toEqual({ type: "session.update", session: { @@ -2101,20 +2017,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("rejects connection when session configuration fails before readiness", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); socket.emit( "message", Buffer.from( @@ -2130,24 +2036,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("treats pre-ready auth errors as a single startup failure", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - onClose, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onError, onClose }); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); socket.emit( "message", Buffer.from( @@ -2175,20 +2069,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("normalizes structured direct OpenAI startup auth errors", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); socket.emit( "message", Buffer.from( @@ -2208,17 +2092,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("normalizes direct OpenAI socket handshake auth errors", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); socket.emit("error", new Error("Unexpected server response: 401")); @@ -2243,17 +2118,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }, ], ])("preserves %s startup auth errors", async (_label, providerConfig) => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ + const bridge = createNativeBridge({ providerConfig, - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); socket.emit("error", new Error("Unexpected server response: 401")); @@ -2262,23 +2130,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("keeps a retried connection ready after delayed startup failure close", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - }); - const failedConnect = bridge.connect(); - const failedSocket = FakeWebSocket.instances[0]; - if (!failedSocket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onClose }); + const { connecting: failedConnect, socket: failedSocket } = beginBridgeConnection(bridge); failedSocket.deferClose = true; - failedSocket.readyState = FakeWebSocket.OPEN; - failedSocket.emit("open"); + openSocket(failedSocket); failedSocket.emit( "message", Buffer.from( @@ -2292,14 +2149,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { await expect(failedConnect).rejects.toThrow(OPENAI_REALTIME_REJECTED_KEY_MESSAGE); expect(failedSocket.deferredClose).toBeDefined(); - const retryConnect = bridge.connect(); - const retrySocket = FakeWebSocket.instances[1]; - if (!retrySocket) { - throw new Error("expected bridge retry to create a websocket"); - } - retrySocket.readyState = FakeWebSocket.OPEN; - retrySocket.emit("open"); - retrySocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const { connecting: retryConnect, socket: retrySocket } = beginBridgeConnection(bridge, 1); + openSocket(retrySocket); + emitSessionUpdated(retrySocket); await retryConnect; expect(bridge.isConnected()).toBe(true); @@ -2309,20 +2161,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("rejects connection when the socket closes before session readiness", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge(); + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); + openSocket(socket); socket.close(1006, "session closed"); await expect(connecting).rejects.toThrow("OpenAI realtime connection closed before ready"); @@ -2331,19 +2173,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("does not report startup timeout shutdown as a clean close", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onClose }); + const { connecting, socket } = beginBridgeConnection(bridge); const timeoutAssertion = expect(connecting).rejects.toThrow( "OpenAI realtime connection timeout", @@ -2357,22 +2189,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("can disable automatic audio turn responses for agent-routed voice loops", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ autoRespondToAudio: false, - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const { connecting, socket } = beginBridgeConnection(bridge); - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + openSocket(socket); + emitSessionUpdated(socket); await connecting; expectRecordFields( @@ -2386,24 +2209,11 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("can disable realtime response interruption while keeping audio responses enabled", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ autoRespondToAudio: true, interruptResponseOnInputAudio: false, - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); expectRecordFields( requireNestedRecord(requireSession(socket), ["audio", "input", "turn_detection"]), @@ -2416,26 +2226,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not locally clear playback on speech-start events when input interruption is disabled", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onClearAudio = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ autoRespondToAudio: true, interruptResponseOnInputAudio: false, onAudio, onClearAudio, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2463,25 +2262,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("keeps assistant playback active on server VAD when automatic audio responses are disabled", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onClearAudio = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ autoRespondToAudio: false, onAudio, onClearAudio, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2509,24 +2297,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("can request PCM16 24 kHz realtime audio for Chrome command-pair bridges", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ audioFormat: REALTIME_VOICE_AUDIO_FORMAT_PCM16_24KHZ, - onAudio: vi.fn(), - onClearAudio: vi.fn(), }); - - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); const session = requireSession(socket); expect(requireNestedRecord(session, ["audio", "input", "format"])).toEqual({ @@ -2540,19 +2314,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("settles cleanly when closed before the websocket opens", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onClose, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } + const bridge = createNativeBridge({ onClose }); + const { connecting, socket } = beginBridgeConnection(bridge); bridge.close(); bridge.close(); @@ -2565,25 +2329,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("truncates externally interrupted playback after an immediate mark acknowledgement", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onClearAudio = vi.fn(); - const bridge: ReturnType = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, onClearAudio, onMark: () => bridge.acknowledgeMark(), }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( @@ -2618,25 +2371,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("preserves FIFO playback acknowledgements after sustained output", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClearAudio = vi.fn(); const onMark = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), + const bridge = createNativeBridge({ onClearAudio, onMark, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); for (let index = 0; index < 300; index += 1) { @@ -2697,24 +2438,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("treats a later named mark as cumulative playback progress", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onMark = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onMark, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onMark }); + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); for (let index = 0; index < 3; index += 1) { @@ -2745,25 +2471,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("forwards current realtime output audio events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onTranscript = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, - onClearAudio: vi.fn(), onTranscript, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); const audio = Buffer.from("assistant audio"); socket.emit( @@ -2795,25 +2509,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("surfaces input transcription failures with their provider error details", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - onEvent, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError, onEvent }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2838,23 +2537,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("preserves corrected final text from legacy realtime text events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onTranscript = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onTranscript, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onTranscript }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2875,26 +2560,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ["invalid alphabet", "not-base64!"], ["non-canonical pad bits", "ZE=="], ])("terminates the session for %s in output audio", async (_scenario, delta) => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onError = vi.fn(); const onClose = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, - onClearAudio: vi.fn(), onError, onClose, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -2921,25 +2595,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("forwards Codex-compatible legacy realtime audio and transcript events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onAudio = vi.fn(); const onTranscript = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret + const bridge = createNativeBridge({ onAudio, - onClearAudio: vi.fn(), onTranscript, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); const audio = Buffer.from("legacy assistant audio"); socket.emit( @@ -2988,26 +2650,10 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("emits tool calls from realtime conversation item done events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onToolCall = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onToolCall, - onEvent, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onToolCall, onEvent }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -3039,24 +2685,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("deduplicates tool calls reported by arguments done and item done events", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onToolCall = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onToolCall, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onToolCall }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -3128,23 +2759,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ])( "uses authoritative completed tool arguments for $name", async ({ delta, finalArguments, expectedArguments }) => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onToolCall = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onToolCall, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onToolCall }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", @@ -3181,24 +2798,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ); it("creates an explicit user item and response for manual speech", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onEvent, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onEvent }); + const socket = await connectReadyBridge(bridge); bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); @@ -3229,7 +2831,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { expect(onEvent).toHaveBeenCalledWith({ direction: "client", type: "conversation.item.create" }); expect(onEvent).toHaveBeenCalledWith({ direction: "client", type: "response.create" }); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), @@ -3242,22 +2844,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("defers manual response.create while a realtime response is active", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3276,30 +2864,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }, ]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); }); it("restores automatic audio responses when a manual response is rejected", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); @@ -3344,24 +2917,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("keeps automatic audio suppressed for unrelated errors during a manual response", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); bridge.triggerGreeting?.("Say exactly: hello from explicit speech."); const sessionUpdatesBeforeError = parseSent(socket).filter( @@ -3383,7 +2941,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { sessionUpdatesBeforeError.length, ); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), @@ -3396,24 +2954,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("flushes a queued manual response after the prior request is rejected", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); bridge.triggerGreeting?.("Say exactly: first greeting."); const firstResponseCreate = parseSent(socket).findLast( @@ -3449,7 +2992,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ); expect(onError).toHaveBeenCalledWith(new Error("bad response request")); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), @@ -3462,22 +3005,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not request a realtime response for continuing tool results", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); void bridge.submitToolResult("call_1", { status: "working" }, { willContinue: true }); @@ -3511,28 +3040,14 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_2" } })), ); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(parseSent(socket).filter((event) => event.type === "response.create")).toHaveLength(1); }); it("does not request a realtime response for suppressed tool results", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); void bridge.submitToolResult( "call_1", @@ -3554,31 +3069,16 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not flush deferred response.create while a tool result is still continuing", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), ); void bridge.submitToolResult("call_1", { status: "working" }, { willContinue: true }); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(onError).not.toHaveBeenCalled(); expect(parseSent(socket).filter((event) => event.type === "response.create")).toEqual([]); @@ -3600,22 +3100,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("drains deferred response.create after response.cancelled", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3628,24 +3114,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("does not send duplicate response.cancel while cancellation is pending", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onEvent, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onEvent }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3680,25 +3151,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("ignores zero-length playback barge-in without clearing audio", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClearAudio = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), + const bridge = createNativeBridge({ onClearAudio, onEvent, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( "message", @@ -3730,25 +3189,13 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("force-cancels zero-length playback barge-in for agent handoff fallback", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClearAudio = vi.fn(); const onEvent = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), + const bridge = createNativeBridge({ onClearAudio, onEvent, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( "message", @@ -3785,26 +3232,15 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("allows immediate playback barge-in when the minimum audio window is zero", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onClearAudio = vi.fn(); - const bridge = provider.createBridge({ + const bridge = createNativeBridge({ providerConfig: { apiKey: "sk-test", // pragma: allowlist secret minBargeInAudioEndMs: 0, }, - onAudio: vi.fn(), onClearAudio, }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( "message", @@ -3836,24 +3272,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("drains deferred response.create after a no-active-response cancellation error", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3885,24 +3306,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("ignores a stale cancellation error after a newer manual response starts", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); bridge.setMediaTimestamp(1000); socket.emit( "message", @@ -3928,7 +3334,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { throw new Error("expected response.cancel event id"); } void bridge.submitToolResult("call_1", { text: "done" }); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); const sessionUpdateCount = parseSent(socket).filter( (event) => event.type === "session.update", ).length; @@ -3952,7 +3358,7 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { ); expect(parseSent(socket).at(-1)).toEqual(expectedResponseCreateEvent()); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), "restored turn detection", @@ -3965,22 +3371,8 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { it("resets deferred response guards after websocket reconnect", async () => { vi.useFakeTimers(); - const provider = buildOpenAIRealtimeVoiceProvider(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge(); + const socket = await connectReadyBridge(bridge); socket.emit( "message", Buffer.from(JSON.stringify({ type: "response.created", response: { id: "resp_1" } })), @@ -3991,14 +3383,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { socket.emit("close", 1006, Buffer.from("transient drop")); await vi.advanceTimersByTimeAsync(1000); - const reconnectedSocket = FakeWebSocket.instances[1]; - if (!reconnectedSocket) { - throw new Error("expected bridge to reconnect"); - } - - reconnectedSocket.readyState = FakeWebSocket.OPEN; - reconnectedSocket.emit("open"); - reconnectedSocket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); + const reconnectedSocket = requireSocket(1); + openSocket(reconnectedSocket); + emitSessionUpdated(reconnectedSocket); bridge.sendUserMessage?.("Say hello after reconnect."); expect(parseSent(reconnectedSocket).slice(-3)).toEqual([ @@ -4016,24 +3403,9 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }); it("turns active-response errors into a deferred response.create retry", async () => { - const provider = buildOpenAIRealtimeVoiceProvider(); const onError = vi.fn(); - const bridge = provider.createBridge({ - providerConfig: { apiKey: "sk-test" }, // pragma: allowlist secret - onAudio: vi.fn(), - onClearAudio: vi.fn(), - onError, - }); - const connecting = bridge.connect(); - const socket = FakeWebSocket.instances[0]; - if (!socket) { - throw new Error("expected bridge to create a websocket"); - } - - socket.readyState = FakeWebSocket.OPEN; - socket.emit("open"); - socket.emit("message", Buffer.from(JSON.stringify({ type: "session.updated" }))); - await connecting; + const bridge = createNativeBridge({ onError }); + const socket = await connectReadyBridge(bridge); void bridge.submitToolResult("call_1", { text: "done" }); const responseCreateEvent = parseSent(socket).findLast( @@ -4065,12 +3437,12 @@ describe("buildOpenAIRealtimeVoiceProvider", () => { }, ); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expect(onError).not.toHaveBeenCalled(); expect(parseSent(socket).slice(-1)).toEqual([expectedResponseCreateEvent()]); - socket.emit("message", Buffer.from(JSON.stringify({ type: "response.done" }))); + emitServerEvent(socket, { type: "response.done" }); expectRecordFields( requireNestedRecord(parseSent(socket).at(-1)?.session, ["audio", "input", "turn_detection"]), diff --git a/extensions/openai/realtime-voice-provider.ts b/extensions/openai/realtime-voice-provider.ts index 9e8e448c65ab..907783437518 100644 --- a/extensions/openai/realtime-voice-provider.ts +++ b/extensions/openai/realtime-voice-provider.ts @@ -1684,8 +1684,10 @@ class OpenAIRealtimeVoiceBridge implements RealtimeVoiceBridge { ) { return; } - this.pendingAudio.push(audio); - this.pendingAudioBytes += audio.byteLength; + // Capture transports can recycle caller-owned views before the provider becomes ready. + const queuedAudio = Buffer.from(audio); + this.pendingAudio.push(queuedAudio); + this.pendingAudioBytes += queuedAudio.byteLength; } private clearPendingAudio(): void { diff --git a/extensions/policy/src/doctor/check-factory.test.ts b/extensions/policy/src/doctor/check-factory.test.ts new file mode 100644 index 000000000000..e06db1d66d37 --- /dev/null +++ b/extensions/policy/src/doctor/check-factory.test.ts @@ -0,0 +1,111 @@ +import type { + HealthCheckContext, + HealthFinding, + HealthRepairContext, + HealthRepairResult, +} from "openclaw/plugin-sdk/health"; +import { describe, expect, it, vi } from "vitest"; +import { createPolicyScopedChecks } from "./check-factory.js"; +import { CHECK_IDS } from "./check-ids.js"; +import type { PolicyEvaluation } from "./types.js"; + +describe("policy scoped health checks", () => { + const evaluation = {} as PolicyEvaluation; + const context = {} as HealthCheckContext; + + it("preserves registration order, descriptions, metadata, and repair capability", () => { + const repair = vi.fn(async (): Promise => ({ changes: [] })); + const checks = createPolicyScopedChecks( + { + evaluatePolicy: vi.fn(async () => evaluation), + findingsForCheck: vi.fn(() => []), + }, + [ + [CHECK_IDS.policyMissingFile, "The policy file exists."], + [CHECK_IDS.policyDeniedChannelProvider, "Channels satisfy policy.", repair], + ], + ); + + expect( + checks.map(({ id, description, kind, source }) => ({ id, description, kind, source })), + ).toEqual([ + { + id: CHECK_IDS.policyMissingFile, + description: "The policy file exists.", + kind: "plugin", + source: "policy", + }, + { + id: CHECK_IDS.policyDeniedChannelProvider, + description: "Channels satisfy policy.", + kind: "plugin", + source: "policy", + }, + ]); + expect(Object.hasOwn(checks[0]!, "repair")).toBe(false); + expect(Object.hasOwn(checks[1]!, "repair")).toBe(true); + expect(checks[1]).toMatchObject({ repair }); + }); + + it("awaits the policy evaluation before selecting findings for the same check", async () => { + const findings: HealthFinding[] = [ + { checkId: CHECK_IDS.policyMissingFile, severity: "error", message: "Missing policy." }, + ]; + let releaseEvaluation!: () => void; + const evaluationGate = new Promise((resolve) => { + releaseEvaluation = resolve; + }); + const evaluatePolicy = vi.fn(async (received: HealthCheckContext) => { + expect(received).toBe(context); + await evaluationGate; + return evaluation; + }); + const findingsForCheck = vi.fn(() => findings); + const [check] = createPolicyScopedChecks({ evaluatePolicy, findingsForCheck }, [ + [CHECK_IDS.policyMissingFile, "The policy file exists."], + ]); + + const result = check!.detect(context); + expect(evaluatePolicy).toHaveBeenCalledOnce(); + expect(findingsForCheck).not.toHaveBeenCalled(); + + releaseEvaluation(); + await expect(result).resolves.toBe(findings); + expect(findingsForCheck).toHaveBeenCalledExactlyOnceWith( + evaluation, + CHECK_IDS.policyMissingFile, + ); + }); + + it("propagates evaluation failures without selecting findings", async () => { + const failure = new Error("policy evaluation failed"); + const evaluatePolicy = vi.fn(async () => { + throw failure; + }); + const findingsForCheck = vi.fn(() => []); + const [check] = createPolicyScopedChecks({ evaluatePolicy, findingsForCheck }, [ + [CHECK_IDS.policyMissingFile, "The policy file exists."], + ]); + + await expect(check!.detect(context)).rejects.toBe(failure); + expect(findingsForCheck).not.toHaveBeenCalled(); + }); + + it("retains the original repair callback and its exact result promise", () => { + const repairContext = {} as HealthRepairContext; + const findings: HealthFinding[] = []; + const pendingRepair = Promise.resolve({ changes: ["repaired"] }); + const repair = vi.fn(() => pendingRepair); + const [check] = createPolicyScopedChecks( + { + evaluatePolicy: vi.fn(async () => evaluation), + findingsForCheck: vi.fn(() => []), + }, + [[CHECK_IDS.policyDeniedChannelProvider, "Channels satisfy policy.", repair]], + ); + + expect(check).toMatchObject({ repair }); + expect(check!.repair!(repairContext, findings)).toBe(pendingRepair); + expect(repair).toHaveBeenCalledExactlyOnceWith(repairContext, findings); + }); +}); diff --git a/extensions/policy/src/doctor/check-factory.ts b/extensions/policy/src/doctor/check-factory.ts new file mode 100644 index 000000000000..2beca23a7cfa --- /dev/null +++ b/extensions/policy/src/doctor/check-factory.ts @@ -0,0 +1,26 @@ +import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import type { POLICY_CHECK_IDS } from "./check-ids.js"; +import type { PolicyDoctorCheckDeps } from "./types.js"; + +type PolicyDoctorCheckDefinition = readonly [ + id: (typeof POLICY_CHECK_IDS)[number], + description: string, + repair?: NonNullable, +]; + +export function createPolicyScopedChecks( + deps: Pick, + definitions: readonly PolicyDoctorCheckDefinition[], +): readonly HealthCheck[] { + const { evaluatePolicy, findingsForCheck } = deps; + return definitions.map(([id, description, repair]) => ({ + id, + kind: "plugin", + description, + source: "policy", + async detect(ctx) { + return findingsForCheck(await evaluatePolicy(ctx), id); + }, + ...(repair ? { repair } : {}), + })); +} diff --git a/extensions/policy/src/doctor/scopes/channels.ts b/extensions/policy/src/doctor/scopes/channels.ts index 7f0c72d6f081..05d6bed85fdc 100644 --- a/extensions/policy/src/doctor/scopes/channels.ts +++ b/extensions/policy/src/doctor/scopes/channels.ts @@ -1,6 +1,7 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; @@ -10,109 +11,66 @@ export function createPolicyChannelProviderChecks( const { channelIdsFromFindings, disableChannels, - evaluatePolicy, - findingsForCheck, workspaceRepairsDisabledResult, workspaceRepairsEnabled, } = deps; - const policyChannelsDeniedProviderCheck: HealthCheck = { - id: CHECK_IDS.policyDeniedChannelProvider, - kind: "plugin", - description: "Configured channels satisfy policy deny rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedChannelProvider); - }, - async repair(ctx, findings) { - if (!workspaceRepairsEnabled(ctx)) { - return workspaceRepairsDisabledResult("channel config"); - } - const channelIds = channelIdsFromFindings(findings); - if (channelIds.length === 0) { + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyDeniedChannelProvider, + "Configured channels satisfy policy deny rules.", + async (ctx, findings) => { + if (!workspaceRepairsEnabled(ctx)) { + return workspaceRepairsDisabledResult("channel config"); + } + const channelIds = channelIdsFromFindings(findings); + if (channelIds.length === 0) { + return { + status: "skipped", + reason: "no channel findings matched a configurable channel", + changes: [], + }; + } + const next = disableChannels(ctx.cfg, channelIds); + if (next.changed.length === 0) { + return { + status: "skipped", + reason: "matching channels were already disabled or missing", + changes: [], + }; + } return { - status: "skipped", - reason: "no channel findings matched a configurable channel", - changes: [], + config: next.config, + changes: next.changed.map( + (id) => `Disabled channels.${id}.enabled for policy conformance.`, + ), }; - } - const next = disableChannels(ctx.cfg, channelIds); - if (next.changed.length === 0) { - return { - status: "skipped", - reason: "matching channels were already disabled or missing", - changes: [], - }; - } - return { - config: next.config, - changes: next.changed.map( - (id) => `Disabled channels.${id}.enabled for policy conformance.`, - ), - }; - }, - }; - - return [policyChannelsDeniedProviderCheck]; + }, + ], + ]); } export function createPolicyIngressChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyIngressDmPolicyUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyIngressDmPolicyUnapproved, - kind: "plugin", - description: "Channel direct-message access policy matches ingress requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmPolicyUnapproved); - }, - }; - const policyIngressDmScopeUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyIngressDmScopeUnapproved, - kind: "plugin", - description: "Direct-message sessions use the policy-required isolation scope.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressDmScopeUnapproved); - }, - }; - const policyIngressOpenGroupsDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyIngressOpenGroupsDenied, - kind: "plugin", - description: "Channel group access does not use open group policy when denied.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyIngressOpenGroupsDenied); - }, - async repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressOpenGroupsDenied); - }, - }; - const policyIngressGroupMentionRequiredCheck: HealthCheck = { - id: CHECK_IDS.policyIngressGroupMentionRequired, - kind: "plugin", - description: "Channel group access keeps mention gates enabled when required.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyIngressGroupMentionRequired, - ); - }, - async repair(ctx, findings) { - return repairPolicyAutomaticNarrower( - ctx, - findings, - CHECK_IDS.policyIngressGroupMentionRequired, - ); - }, - }; - - return [ - policyIngressDmPolicyUnapprovedCheck, - policyIngressDmScopeUnapprovedCheck, - policyIngressOpenGroupsDeniedCheck, - policyIngressGroupMentionRequiredCheck, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyIngressDmPolicyUnapproved, + "Channel direct-message access policy matches ingress requirements.", + ], + [ + CHECK_IDS.policyIngressDmScopeUnapproved, + "Direct-message sessions use the policy-required isolation scope.", + ], + [ + CHECK_IDS.policyIngressOpenGroupsDenied, + "Channel group access does not use open group policy when denied.", + async (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressOpenGroupsDenied), + ], + [ + CHECK_IDS.policyIngressGroupMentionRequired, + "Channel group access keeps mention gates enabled when required.", + async (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyIngressGroupMentionRequired), + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/core.ts b/extensions/policy/src/doctor/scopes/core.ts index 7255cc6d0c21..21d93305ad16 100644 --- a/extensions/policy/src/doctor/scopes/core.ts +++ b/extensions/policy/src/doctor/scopes/core.ts @@ -1,52 +1,17 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyCoreChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyMissingFileCheck: HealthCheck = { - id: CHECK_IDS.policyMissingFile, - kind: "plugin", - description: "The enabled Policy plugin has a policy file to verify.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingFile); - }, - }; - const policyHashMismatchCheck: HealthCheck = { - id: CHECK_IDS.policyHashMismatch, - kind: "plugin", - description: "The policy file matches the configured expected hash.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyHashMismatch); - }, - }; - const policyAttestationMismatchCheck: HealthCheck = { - id: CHECK_IDS.policyAttestationMismatch, - kind: "plugin", - description: "The current policy check matches the accepted attestation.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAttestationMismatch); - }, - }; - const policyInvalidFileCheck: HealthCheck = { - id: CHECK_IDS.policyInvalidFile, - kind: "plugin", - description: "The enabled policy file parses before policy checks run.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyInvalidFile); - }, - }; - - return [ - policyMissingFileCheck, - policyInvalidFileCheck, - policyHashMismatchCheck, - policyAttestationMismatchCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policyMissingFile, "The enabled Policy plugin has a policy file to verify."], + [CHECK_IDS.policyInvalidFile, "The enabled policy file parses before policy checks run."], + [CHECK_IDS.policyHashMismatch, "The policy file matches the configured expected hash."], + [ + CHECK_IDS.policyAttestationMismatch, + "The current policy check matches the accepted attestation.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/data-auth.ts b/extensions/policy/src/doctor/scopes/data-auth.ts index 866e6480f23a..9bb0fbf15248 100644 --- a/extensions/policy/src/doctor/scopes/data-auth.ts +++ b/extensions/policy/src/doctor/scopes/data-auth.ts @@ -1,118 +1,49 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyDataAuthChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyDataHandlingTelemetryContentCaptureCheck: HealthCheck = { - id: CHECK_IDS.policyDataHandlingTelemetryContentCapture, - kind: "plugin", - description: "Telemetry content capture remains disabled when policy denies it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyDataHandlingTelemetryContentCapture, - ); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower( - ctx, - findings, - CHECK_IDS.policyDataHandlingTelemetryContentCapture, - ); - }, - }; - const policyDataHandlingSessionRetentionNotEnforcedCheck: HealthCheck = { - id: CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, - kind: "plugin", - description: "Session retention maintenance is enforced when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, - ); - }, - }; - const policyDataHandlingSessionTranscriptMemoryCheck: HealthCheck = { - id: CHECK_IDS.policyDataHandlingSessionTranscriptMemory, - kind: "plugin", - description: "Session transcript memory indexing remains disabled when policy denies it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyDataHandlingSessionTranscriptMemory, - ); - }, - }; - const policySecretsUnmanagedProviderCheck: HealthCheck = { - id: CHECK_IDS.policySecretsUnmanagedProvider, - kind: "plugin", - description: + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyDataHandlingTelemetryContentCapture, + "Telemetry content capture remains disabled when policy denies it.", + (ctx, findings) => + repairPolicyAutomaticNarrower( + ctx, + findings, + CHECK_IDS.policyDataHandlingTelemetryContentCapture, + ), + ], + [ + CHECK_IDS.policyDataHandlingSessionRetentionNotEnforced, + "Session retention maintenance is enforced when policy requires it.", + ], + [ + CHECK_IDS.policyDataHandlingSessionTranscriptMemory, + "Session transcript memory indexing remains disabled when policy denies it.", + ], + [ + CHECK_IDS.policySecretsUnmanagedProvider, "OpenClaw config SecretRefs use configured secret providers when policy requires managed providers.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsUnmanagedProvider); - }, - }; - const policySecretsDeniedProviderSourceCheck: HealthCheck = { - id: CHECK_IDS.policySecretsDeniedProviderSource, - kind: "plugin", - description: + ], + [ + CHECK_IDS.policySecretsDeniedProviderSource, "OpenClaw config secret providers and SecretRefs do not use sources denied by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySecretsDeniedProviderSource, - ); - }, - }; - const policySecretsInsecureProviderCheck: HealthCheck = { - id: CHECK_IDS.policySecretsInsecureProvider, - kind: "plugin", - description: + ], + [ + CHECK_IDS.policySecretsInsecureProvider, "Configured secret providers do not opt into insecure posture unless policy allows it.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySecretsInsecureProvider); - }, - }; - const policyAuthProfileInvalidMetadataCheck: HealthCheck = { - id: CHECK_IDS.policyAuthProfileInvalidMetadata, - kind: "plugin", - description: "OpenClaw config auth profiles declare required provider and mode metadata.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyAuthProfileInvalidMetadata, - ); - }, - }; - const policyAuthProfileUnapprovedModeCheck: HealthCheck = { - id: CHECK_IDS.policyAuthProfileUnapprovedMode, - kind: "plugin", - description: "OpenClaw config auth profile modes stay within the policy allowlist.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAuthProfileUnapprovedMode); - }, - }; - - return [ - policyDataHandlingTelemetryContentCaptureCheck, - policyDataHandlingSessionRetentionNotEnforcedCheck, - policyDataHandlingSessionTranscriptMemoryCheck, - policySecretsUnmanagedProviderCheck, - policySecretsDeniedProviderSourceCheck, - policySecretsInsecureProviderCheck, - policyAuthProfileInvalidMetadataCheck, - policyAuthProfileUnapprovedModeCheck, - ]; + ], + [ + CHECK_IDS.policyAuthProfileInvalidMetadata, + "OpenClaw config auth profiles declare required provider and mode metadata.", + ], + [ + CHECK_IDS.policyAuthProfileUnapprovedMode, + "OpenClaw config auth profile modes stay within the policy allowlist.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/exec-approvals.ts b/extensions/policy/src/doctor/scopes/exec-approvals.ts index bf9f10d08f79..605502fa7860 100644 --- a/extensions/policy/src/doctor/scopes/exec-approvals.ts +++ b/extensions/policy/src/doctor/scopes/exec-approvals.ts @@ -1,100 +1,40 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyExecApprovalChecks( deps: PolicyDoctorCheckDeps, ): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyExecApprovalsMissingCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsMissing, - kind: "plugin", - description: "Required exec approvals artifact is present for policy conformance.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsMissing); - }, - }; - const policyExecApprovalsInvalidCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsInvalid, - kind: "plugin", - description: "Exec approvals artifact parses before policy checks run.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyExecApprovalsInvalid); - }, - }; - const policyExecApprovalsDefaultSecurityUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, - kind: "plugin", - description: "Exec approval defaults use a policy-approved security mode.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, - ); - }, - }; - const policyExecApprovalsAgentSecurityUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, - kind: "plugin", - description: "Per-agent exec approval settings use policy-approved security modes.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, - ); - }, - }; - const policyExecApprovalsAutoAllowSkillsEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, - kind: "plugin", - description: + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyExecApprovalsMissing, + "Required exec approvals artifact is present for policy conformance.", + ], + [ + CHECK_IDS.policyExecApprovalsInvalid, + "Exec approvals artifact parses before policy checks run.", + ], + [ + CHECK_IDS.policyExecApprovalsDefaultSecurityUnapproved, + "Exec approval defaults use a policy-approved security mode.", + ], + [ + CHECK_IDS.policyExecApprovalsAgentSecurityUnapproved, + "Per-agent exec approval settings use policy-approved security modes.", + ], + [ + CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, "Exec approval agents do not implicitly auto-allow skill CLIs unless policy allows it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAutoAllowSkillsEnabled, - ); - }, - }; - const policyExecApprovalsAllowlistMissingCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAllowlistMissing, - kind: "plugin", - description: "Exec approval allowlists include every pattern required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAllowlistMissing, - ); - }, - }; - const policyExecApprovalsAllowlistUnexpectedCheck: HealthCheck = { - id: CHECK_IDS.policyExecApprovalsAllowlistUnexpected, - kind: "plugin", - description: "Exec approval allowlists do not contain patterns outside policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyExecApprovalsAllowlistUnexpected, - ); - }, - }; - - return [ - policyExecApprovalsMissingCheck, - policyExecApprovalsInvalidCheck, - policyExecApprovalsDefaultSecurityUnapprovedCheck, - policyExecApprovalsAgentSecurityUnapprovedCheck, - policyExecApprovalsAutoAllowSkillsEnabledCheck, - policyExecApprovalsAllowlistMissingCheck, - policyExecApprovalsAllowlistUnexpectedCheck, - ]; + ], + [ + CHECK_IDS.policyExecApprovalsAllowlistMissing, + "Exec approval allowlists include every pattern required by policy.", + ], + [ + CHECK_IDS.policyExecApprovalsAllowlistUnexpected, + "Exec approval allowlists do not contain patterns outside policy.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/gateway.ts b/extensions/policy/src/doctor/scopes/gateway.ts index 8418e9143b38..1916a212cef5 100644 --- a/extensions/policy/src/doctor/scopes/gateway.ts +++ b/extensions/policy/src/doctor/scopes/gateway.ts @@ -3,140 +3,58 @@ import { isRecord } from "openclaw/plugin-sdk/channel-secret-basic-runtime"; import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health"; import type { PolicyEvidence } from "../../policy-state.js"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import { previewPolicyReviewRequiredRepair } from "../review-required-repairs.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; import { readPolicyBoolean, readStringList } from "../utils.js"; export function createPolicyGatewayChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyGatewayNonLoopbackBindCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayNonLoopbackBind, - kind: "plugin", - description: "Gateway bind posture matches policy exposure requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNonLoopbackBind); - }, - repair(ctx, findings) { - return previewPolicyReviewRequiredRepair( - ctx, - findings, - CHECK_IDS.policyGatewayNonLoopbackBind, - ); - }, - }; - const policyGatewayAuthDisabledCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayAuthDisabled, - kind: "plugin", - description: "Gateway authentication remains enabled when required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayAuthDisabled); - }, - }; - const policyGatewayRateLimitMissingCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayRateLimitMissing, - kind: "plugin", - description: "Gateway authentication rate-limit posture is explicit when required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRateLimitMissing); - }, - }; - const policyGatewayControlUiInsecureCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayControlUiInsecure, - kind: "plugin", - description: "Gateway Control UI insecure exposure toggles remain disabled by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayControlUiInsecure); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayControlUiInsecure); - }, - }; - const policyGatewayTailscaleFunnelCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayTailscaleFunnel, - kind: "plugin", - description: "Gateway Tailscale Funnel exposure matches policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayTailscaleFunnel); - }, - }; - const policyGatewayRemoteEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayRemoteEnabled, - kind: "plugin", - description: "Remote gateway mode matches policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayRemoteEnabled); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayRemoteEnabled); - }, - }; - const policyGatewayHttpEndpointEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayHttpEndpointEnabled, - kind: "plugin", - description: "Gateway HTTP API endpoints match policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyGatewayHttpEndpointEnabled, - ); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower( - ctx, - findings, - CHECK_IDS.policyGatewayHttpEndpointEnabled, - ); - }, - }; - const policyGatewayHttpUrlFetchUnrestrictedCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, - kind: "plugin", - description: "Gateway HTTP URL-fetch inputs have allowlists when required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, - ); - }, - }; - const policyGatewayNodeCommandDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyGatewayNodeCommandDenied, - kind: "plugin", - description: "Gateway node command allowlists match policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyGatewayNodeCommandDenied); - }, - repair(ctx, findings) { - return previewPolicyReviewRequiredRepair( - ctx, - findings, - CHECK_IDS.policyGatewayNodeCommandDenied, - ); - }, - }; - - return [ - policyGatewayNonLoopbackBindCheck, - policyGatewayAuthDisabledCheck, - policyGatewayRateLimitMissingCheck, - policyGatewayControlUiInsecureCheck, - policyGatewayTailscaleFunnelCheck, - policyGatewayRemoteEnabledCheck, - policyGatewayHttpEndpointEnabledCheck, - policyGatewayHttpUrlFetchUnrestrictedCheck, - policyGatewayNodeCommandDeniedCheck, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyGatewayNonLoopbackBind, + "Gateway bind posture matches policy exposure requirements.", + (ctx, findings) => + previewPolicyReviewRequiredRepair(ctx, findings, CHECK_IDS.policyGatewayNonLoopbackBind), + ], + [ + CHECK_IDS.policyGatewayAuthDisabled, + "Gateway authentication remains enabled when required by policy.", + ], + [ + CHECK_IDS.policyGatewayRateLimitMissing, + "Gateway authentication rate-limit posture is explicit when required by policy.", + ], + [ + CHECK_IDS.policyGatewayControlUiInsecure, + "Gateway Control UI insecure exposure toggles remain disabled by policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayControlUiInsecure), + ], + [CHECK_IDS.policyGatewayTailscaleFunnel, "Gateway Tailscale Funnel exposure matches policy."], + [ + CHECK_IDS.policyGatewayRemoteEnabled, + "Remote gateway mode matches policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayRemoteEnabled), + ], + [ + CHECK_IDS.policyGatewayHttpEndpointEnabled, + "Gateway HTTP API endpoints match policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyGatewayHttpEndpointEnabled), + ], + [ + CHECK_IDS.policyGatewayHttpUrlFetchUnrestricted, + "Gateway HTTP URL-fetch inputs have allowlists when required by policy.", + ], + [ + CHECK_IDS.policyGatewayNodeCommandDenied, + "Gateway node command allowlists match policy.", + (ctx, findings) => + previewPolicyReviewRequiredRepair(ctx, findings, CHECK_IDS.policyGatewayNodeCommandDenied), + ], + ]); } export function gatewayExposureFindings( diff --git a/extensions/policy/src/doctor/scopes/model-network.ts b/extensions/policy/src/doctor/scopes/model-network.ts index 6ec13e916da4..17ce92db5791 100644 --- a/extensions/policy/src/doctor/scopes/model-network.ts +++ b/extensions/policy/src/doctor/scopes/model-network.ts @@ -2,6 +2,7 @@ import type { HealthCheck, HealthFinding } from "openclaw/plugin-sdk/health"; import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared"; import type { PolicyEvidence } from "../../policy-state.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; import { readPolicyBoolean, readStringList } from "../utils.js"; @@ -9,61 +10,25 @@ import { readPolicyBoolean, readStringList } from "../utils.js"; export function createPolicyModelNetworkChecks( deps: PolicyDoctorCheckDeps, ): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyMcpDeniedServerCheck: HealthCheck = { - id: CHECK_IDS.policyDeniedMcpServer, - kind: "plugin", - description: "Configured MCP servers do not match policy deny rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedMcpServer); - }, - }; - const policyMcpUnapprovedServerCheck: HealthCheck = { - id: CHECK_IDS.policyUnapprovedMcpServer, - kind: "plugin", - description: "Configured MCP servers do not match policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedMcpServer); - }, - }; - const policyModelsDeniedProviderCheck: HealthCheck = { - id: CHECK_IDS.policyDeniedModelProvider, - kind: "plugin", - description: "Configured model providers do not match policy deny rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyDeniedModelProvider); - }, - }; - const policyModelsUnapprovedProviderCheck: HealthCheck = { - id: CHECK_IDS.policyUnapprovedModelProvider, - kind: "plugin", - description: "Configured model providers do not match policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnapprovedModelProvider); - }, - }; - const policyNetworkPrivateAccessCheck: HealthCheck = { - id: CHECK_IDS.policyPrivateNetworkAccess, - kind: "plugin", - description: "Network SSRF policy settings match private-network requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyPrivateNetworkAccess); - }, - }; - - return [ - policyMcpDeniedServerCheck, - policyMcpUnapprovedServerCheck, - policyModelsDeniedProviderCheck, - policyModelsUnapprovedProviderCheck, - policyNetworkPrivateAccessCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policyDeniedMcpServer, "Configured MCP servers do not match policy deny rules."], + [ + CHECK_IDS.policyUnapprovedMcpServer, + "Configured MCP servers do not match policy allow rules.", + ], + [ + CHECK_IDS.policyDeniedModelProvider, + "Configured model providers do not match policy deny rules.", + ], + [ + CHECK_IDS.policyUnapprovedModelProvider, + "Configured model providers do not match policy allow rules.", + ], + [ + CHECK_IDS.policyPrivateNetworkAccess, + "Network SSRF policy settings match private-network requirements.", + ], + ]); } export function mcpServerFindings( diff --git a/extensions/policy/src/doctor/scopes/routing.ts b/extensions/policy/src/doctor/scopes/routing.ts index f40241ac2ce7..c7b3bc0801a1 100644 --- a/extensions/policy/src/doctor/scopes/routing.ts +++ b/extensions/policy/src/doctor/scopes/routing.ts @@ -1,51 +1,25 @@ import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyRoutingChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - return [ - { - id: CHECK_IDS.policyRoutingBindingsRequired, - kind: "plugin", - description: "Routing policy has at least one channel route binding when required.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyRoutingBindingsRequired); - }, - }, - { - id: CHECK_IDS.policyRoutingBindingChannelUnconfigured, - kind: "plugin", - description: "Route bindings name channels present in configuration.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyRoutingBindingChannelUnconfigured, - ); - }, - }, - { - id: CHECK_IDS.policyRoutingAgentMismatch, - kind: "plugin", - description: "Authored routing probes resolve to their expected agents.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyRoutingAgentMismatch); - }, - }, - { - id: CHECK_IDS.policyRoutingMatchKindMismatch, - kind: "plugin", - description: "Authored routing probes match at their expected specificity.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyRoutingMatchKindMismatch, - ); - }, - }, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyRoutingBindingsRequired, + "Routing policy has at least one channel route binding when required.", + ], + [ + CHECK_IDS.policyRoutingBindingChannelUnconfigured, + "Route bindings name channels present in configuration.", + ], + [ + CHECK_IDS.policyRoutingAgentMismatch, + "Authored routing probes resolve to their expected agents.", + ], + [ + CHECK_IDS.policyRoutingMatchKindMismatch, + "Authored routing probes match at their expected specificity.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/sandbox.ts b/extensions/policy/src/doctor/scopes/sandbox.ts index 5988614b6fca..9e526da5e73a 100644 --- a/extensions/policy/src/doctor/scopes/sandbox.ts +++ b/extensions/policy/src/doctor/scopes/sandbox.ts @@ -1,123 +1,43 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicySandboxChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policySandboxModeUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxModeUnapproved, - kind: "plugin", - description: "Sandbox mode config satisfies policy requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxModeUnapproved); - }, - }; - const policySandboxBackendUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxBackendUnapproved, - kind: "plugin", - description: "Sandbox backend config satisfies policy requirements.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policySandboxBackendUnapproved); - }, - }; - const policySandboxContainerPostureUnobservableCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerPostureUnobservable, - kind: "plugin", - description: "Sandbox container posture policy only targets observable container backends.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerPostureUnobservable, - ); - }, - }; - const policySandboxContainerHostNetworkDeniedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerHostNetworkDenied, - kind: "plugin", - description: "Sandbox container config avoids host network mode.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerHostNetworkDenied, - ); - }, - }; - const policySandboxContainerNamespaceJoinDeniedCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerNamespaceJoinDenied, - kind: "plugin", - description: "Sandbox container config avoids joining another container network namespace.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerNamespaceJoinDenied, - ); - }, - }; - const policySandboxContainerMountModeRequiredCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerMountModeRequired, - kind: "plugin", - description: "Sandbox container mounts are read-only when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerMountModeRequired, - ); - }, - }; - const policySandboxContainerRuntimeSocketMountCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerRuntimeSocketMount, - kind: "plugin", - description: "Sandbox container mounts avoid host container runtime sockets.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerRuntimeSocketMount, - ); - }, - }; - const policySandboxContainerUnconfinedProfileCheck: HealthCheck = { - id: CHECK_IDS.policySandboxContainerUnconfinedProfile, - kind: "plugin", - description: "Sandbox container profile config avoids unconfined profiles.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxContainerUnconfinedProfile, - ); - }, - }; - const policySandboxBrowserCdpSourceRangeMissingCheck: HealthCheck = { - id: CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, - kind: "plugin", - description: "Sandbox browser CDP config includes a source range when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, - ); - }, - }; - - return [ - policySandboxModeUnapprovedCheck, - policySandboxBackendUnapprovedCheck, - policySandboxContainerPostureUnobservableCheck, - policySandboxContainerHostNetworkDeniedCheck, - policySandboxContainerNamespaceJoinDeniedCheck, - policySandboxContainerMountModeRequiredCheck, - policySandboxContainerRuntimeSocketMountCheck, - policySandboxContainerUnconfinedProfileCheck, - policySandboxBrowserCdpSourceRangeMissingCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policySandboxModeUnapproved, "Sandbox mode config satisfies policy requirements."], + [ + CHECK_IDS.policySandboxBackendUnapproved, + "Sandbox backend config satisfies policy requirements.", + ], + [ + CHECK_IDS.policySandboxContainerPostureUnobservable, + "Sandbox container posture policy only targets observable container backends.", + ], + [ + CHECK_IDS.policySandboxContainerHostNetworkDenied, + "Sandbox container config avoids host network mode.", + ], + [ + CHECK_IDS.policySandboxContainerNamespaceJoinDenied, + "Sandbox container config avoids joining another container network namespace.", + ], + [ + CHECK_IDS.policySandboxContainerMountModeRequired, + "Sandbox container mounts are read-only when policy requires it.", + ], + [ + CHECK_IDS.policySandboxContainerRuntimeSocketMount, + "Sandbox container mounts avoid host container runtime sockets.", + ], + [ + CHECK_IDS.policySandboxContainerUnconfinedProfile, + "Sandbox container profile config avoids unconfined profiles.", + ], + [ + CHECK_IDS.policySandboxBrowserCdpSourceRangeMissing, + "Sandbox browser CDP config includes a source range when policy requires it.", + ], + ]); } diff --git a/extensions/policy/src/doctor/scopes/tools.ts b/extensions/policy/src/doctor/scopes/tools.ts index 087d75f36d12..e977383e3757 100644 --- a/extensions/policy/src/doctor/scopes/tools.ts +++ b/extensions/policy/src/doctor/scopes/tools.ts @@ -1,211 +1,77 @@ // Policy doctor health-check factories for one policy scope. import type { HealthCheck } from "openclaw/plugin-sdk/health"; import { repairPolicyAutomaticNarrower } from "../automatic-repairs.js"; +import { createPolicyScopedChecks } from "../check-factory.js"; import { CHECK_IDS } from "../check-ids.js"; import type { PolicyDoctorCheckDeps } from "../types.js"; export function createPolicyAgentToolChecks(deps: PolicyDoctorCheckDeps): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyAgentsWorkspaceAccessDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyAgentsWorkspaceAccessDenied, - kind: "plugin", - description: "Agent sandbox workspace access matches policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyAgentsWorkspaceAccessDenied, - ); - }, - }; - const policyAgentsToolNotDeniedCheck: HealthCheck = { - id: CHECK_IDS.policyAgentsToolNotDenied, - kind: "plugin", - description: "Agent workspace mutation/runtime tools are denied when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyAgentsToolNotDenied); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyAgentsToolNotDenied); - }, - }; - const policyToolsProfileUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsProfileUnapproved, - kind: "plugin", - description: "Configured tool profiles match policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsProfileUnapproved); - }, - }; - const policyToolsFsWorkspaceOnlyRequiredCheck: HealthCheck = { - id: CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, - kind: "plugin", - description: "Filesystem tools use workspace-only posture when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, - ); - }, - }; - const policyToolsExecSecurityUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsExecSecurityUnapproved, - kind: "plugin", - description: "Exec tool security mode matches policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck( - await evaluatePolicy(ctx), - CHECK_IDS.policyToolsExecSecurityUnapproved, - ); - }, - }; - const policyToolsExecAskUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsExecAskUnapproved, - kind: "plugin", - description: "Exec tool ask mode matches policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecAskUnapproved); - }, - }; - const policyToolsExecHostUnapprovedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsExecHostUnapproved, - kind: "plugin", - description: "Exec tool host routing matches policy allow rules.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsExecHostUnapproved); - }, - }; - const policyToolsElevatedEnabledCheck: HealthCheck = { - id: CHECK_IDS.policyToolsElevatedEnabled, - kind: "plugin", - description: "Elevated tool mode remains disabled when policy requires it.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsElevatedEnabled); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsElevatedEnabled); - }, - }; - const policyToolsAlsoAllowMissingCheck: HealthCheck = { - id: CHECK_IDS.policyToolsAlsoAllowMissing, - kind: "plugin", - description: "Configured tools.alsoAllow entries include policy expected lists.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowMissing); - }, - }; - const policyToolsAlsoAllowUnexpectedCheck: HealthCheck = { - id: CHECK_IDS.policyToolsAlsoAllowUnexpected, - kind: "plugin", - description: "Configured tools.alsoAllow entries match policy expected lists.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsAlsoAllowUnexpected); - }, - }; - const policyToolsRequiredDenyMissingCheck: HealthCheck = { - id: CHECK_IDS.policyToolsRequiredDenyMissing, - kind: "plugin", - description: "Configured tool deny lists include tools required by policy.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyToolsRequiredDenyMissing); - }, - repair(ctx, findings) { - return repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsRequiredDenyMissing); - }, - }; - - return [ - policyAgentsWorkspaceAccessDeniedCheck, - policyAgentsToolNotDeniedCheck, - policyToolsProfileUnapprovedCheck, - policyToolsFsWorkspaceOnlyRequiredCheck, - policyToolsExecSecurityUnapprovedCheck, - policyToolsExecAskUnapprovedCheck, - policyToolsExecHostUnapprovedCheck, - policyToolsElevatedEnabledCheck, - policyToolsAlsoAllowMissingCheck, - policyToolsAlsoAllowUnexpectedCheck, - policyToolsRequiredDenyMissingCheck, - ]; + return createPolicyScopedChecks(deps, [ + [CHECK_IDS.policyAgentsWorkspaceAccessDenied, "Agent sandbox workspace access matches policy."], + [ + CHECK_IDS.policyAgentsToolNotDenied, + "Agent workspace mutation/runtime tools are denied when policy requires it.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyAgentsToolNotDenied), + ], + [CHECK_IDS.policyToolsProfileUnapproved, "Configured tool profiles match policy allow rules."], + [ + CHECK_IDS.policyToolsFsWorkspaceOnlyRequired, + "Filesystem tools use workspace-only posture when policy requires it.", + ], + [ + CHECK_IDS.policyToolsExecSecurityUnapproved, + "Exec tool security mode matches policy allow rules.", + ], + [CHECK_IDS.policyToolsExecAskUnapproved, "Exec tool ask mode matches policy allow rules."], + [CHECK_IDS.policyToolsExecHostUnapproved, "Exec tool host routing matches policy allow rules."], + [ + CHECK_IDS.policyToolsElevatedEnabled, + "Elevated tool mode remains disabled when policy requires it.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsElevatedEnabled), + ], + [ + CHECK_IDS.policyToolsAlsoAllowMissing, + "Configured tools.alsoAllow entries include policy expected lists.", + ], + [ + CHECK_IDS.policyToolsAlsoAllowUnexpected, + "Configured tools.alsoAllow entries match policy expected lists.", + ], + [ + CHECK_IDS.policyToolsRequiredDenyMissing, + "Configured tool deny lists include tools required by policy.", + (ctx, findings) => + repairPolicyAutomaticNarrower(ctx, findings, CHECK_IDS.policyToolsRequiredDenyMissing), + ], + ]); } export function createPolicyToolMetadataChecks( deps: PolicyDoctorCheckDeps, ): readonly HealthCheck[] { - const { evaluatePolicy, findingsForCheck } = deps; - - const policyUnmigratedToolsFileCheck: HealthCheck = { - id: CHECK_IDS.policyUnmigratedToolsFile, - kind: "plugin", - description: "Governed tool declarations have been migrated from TOOLS.md into AGENTS.md.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnmigratedToolsFile); - }, - }; - const policyToolsMissingRiskCheck: HealthCheck = { - id: CHECK_IDS.policyMissingToolRisk, - kind: "plugin", - description: "AGENTS.md tool policy entries declare explicit risk levels.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolRisk); - }, - }; - const policyToolsUnknownRiskCheck: HealthCheck = { - id: CHECK_IDS.policyUnknownToolRisk, - kind: "plugin", - description: "AGENTS.md tool policy entries use known risk levels.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolRisk); - }, - }; - const policyToolsMissingSensitivityCheck: HealthCheck = { - id: CHECK_IDS.policyMissingToolSensitivity, - kind: "plugin", - description: "AGENTS.md tool policy entries declare default artifact sensitivity.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolSensitivity); - }, - }; - const policyToolsUnknownSensitivityCheck: HealthCheck = { - id: CHECK_IDS.policyUnknownToolSensitivity, - kind: "plugin", - description: "AGENTS.md tool policy entries use known sensitivity levels.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyUnknownToolSensitivity); - }, - }; - const policyToolsMissingOwnerCheck: HealthCheck = { - id: CHECK_IDS.policyMissingToolOwner, - kind: "plugin", - description: "AGENTS.md tool policy entries declare an accountable owner.", - source: "policy", - async detect(ctx) { - return findingsForCheck(await evaluatePolicy(ctx), CHECK_IDS.policyMissingToolOwner); - }, - }; - - return [ - policyUnmigratedToolsFileCheck, - policyToolsMissingRiskCheck, - policyToolsUnknownRiskCheck, - policyToolsMissingSensitivityCheck, - policyToolsMissingOwnerCheck, - policyToolsUnknownSensitivityCheck, - ]; + return createPolicyScopedChecks(deps, [ + [ + CHECK_IDS.policyUnmigratedToolsFile, + "Governed tool declarations have been migrated from TOOLS.md into AGENTS.md.", + ], + [ + CHECK_IDS.policyMissingToolRisk, + "AGENTS.md tool policy entries declare explicit risk levels.", + ], + [CHECK_IDS.policyUnknownToolRisk, "AGENTS.md tool policy entries use known risk levels."], + [ + CHECK_IDS.policyMissingToolSensitivity, + "AGENTS.md tool policy entries declare default artifact sensitivity.", + ], + [ + CHECK_IDS.policyMissingToolOwner, + "AGENTS.md tool policy entries declare an accountable owner.", + ], + [ + CHECK_IDS.policyUnknownToolSensitivity, + "AGENTS.md tool policy entries use known sensitivity levels.", + ], + ]); } diff --git a/extensions/whatsapp/src/qa-driver.runtime.test.ts b/extensions/whatsapp/src/qa-driver.runtime.test.ts index f4395916ff73..45d0e2b08561 100644 --- a/extensions/whatsapp/src/qa-driver.runtime.test.ts +++ b/extensions/whatsapp/src/qa-driver.runtime.test.ts @@ -462,6 +462,100 @@ describe("startWhatsAppQaDriverSession", () => { await session.close(); }); + it.each([ + ...[ + { name: "captionless video note", message: { ptvMessage: {} } }, + { + name: "ephemeral captionless video note", + message: { ephemeralMessage: { message: { ptvMessage: {} } } }, + }, + { + name: "edited captionless video note", + message: { editedMessage: { message: { ptvMessage: {} } } }, + }, + ].map(({ name, message }) => ({ + name, + message, + expected: { kind: "media", mediaType: "video/mp4", text: "" }, + })), + ...[ + "pollCreationMessage", + "pollCreationMessageV2", + "pollCreationMessageV3", + "pollCreationMessageV5", + ].flatMap((pollKey) => { + const poll = { + [pollKey]: { + name: "Choose a time", + options: [{ optionName: "Morning" }, { optionName: "Afternoon" }], + }, + }; + const expected = { + kind: "poll", + poll: { question: "Choose a time", options: ["Morning", "Afternoon"] }, + }; + return [ + { name: pollKey, message: poll, expected }, + { + name: `ephemeral ${pollKey}`, + message: { ephemeralMessage: { message: poll } }, + expected, + }, + ]; + }), + ...["pollCreationMessageV3", "pollCreationMessageV5"].flatMap((pollKey) => { + const poll = { + [pollKey]: { + name: "Choose a time", + options: [{ optionName: "Morning" }, { optionName: "Afternoon" }], + }, + }; + const wrappedPoll = { pollCreationMessageV4: { message: poll } }; + const expected = { + kind: "poll", + poll: { question: "Choose a time", options: ["Morning", "Afternoon"] }, + }; + return [ + { name: `future-proof version-4 ${pollKey}`, message: wrappedPoll, expected }, + { + name: `ephemeral future-proof version-4 ${pollKey}`, + message: { ephemeralMessage: { message: wrappedPoll } }, + expected, + }, + { name: `edited ${pollKey}`, message: { editedMessage: { message: poll } }, expected }, + ]; + }), + ])("resolves live ingress waiters for $name", async ({ message, expected }) => { + const sock = createMockSocket(); + mocks.createWaSocket.mockResolvedValue(sock); + mocks.waitForWaConnection.mockResolvedValue(undefined); + mocks.jidToE164.mockReturnValue("+15551234567"); + + const session = await startWhatsAppQaDriverSession({ + authDir: "/tmp/openclaw-whatsapp-auth", + }); + + try { + const observed = session.waitForMessage({ + timeoutMs: 150, + match: (candidate) => candidate.kind === expected.kind, + }); + sock.ev.emit("messages.upsert", { + messages: [ + { + key: { fromMe: false, id: "observed-message", remoteJid: "12345@lid" }, + message, + } as WAMessage, + ], + }); + + await expect(observed).resolves.toMatchObject(expected); + expect(session.getObservedMessages()).toHaveLength(1); + } finally { + await session.close(); + } + }); + it("uses canonical WhatsApp media MIME defaults when Baileys omits MIME", async () => { const sock = createMockSocket(); mocks.createWaSocket.mockResolvedValue(sock); diff --git a/extensions/whatsapp/src/qa-driver.runtime.ts b/extensions/whatsapp/src/qa-driver.runtime.ts index dab09b25ad8b..43462d160449 100644 --- a/extensions/whatsapp/src/qa-driver.runtime.ts +++ b/extensions/whatsapp/src/qa-driver.runtime.ts @@ -1,5 +1,5 @@ // Whatsapp plugin module implements qa driver behavior. -import type { ConnectionState, proto, WAMessage } from "baileys"; +import { getContentType, type ConnectionState, type proto, type WAMessage } from "baileys"; import { formatLocationText } from "openclaw/plugin-sdk/channel-inbound"; import { describeReplyContext, @@ -180,19 +180,10 @@ function findMessageSection( if (current.depth >= 4) { continue; } - for (const wrapperName of [ - "botInvokeMessage", - "documentWithCaptionMessage", - "ephemeralMessage", - "groupMentionedMessage", - "viewOnceMessage", - "viewOnceMessageV2", - "viewOnceMessageV2Extension", - ]) { - const wrapper = current.value[wrapperName]; - if (isRecord(wrapper) && isRecord(wrapper.message)) { - queue.push({ depth: current.depth + 1, value: wrapper.message }); - } + const contentType = getContentType(current.value as proto.IMessage); + const wrapper = contentType ? current.value[contentType] : undefined; + if (isRecord(wrapper) && isRecord(wrapper.message)) { + queue.push({ depth: current.depth + 1, value: wrapper.message }); } } return undefined; @@ -218,6 +209,7 @@ function readPoll(message: unknown): WhatsAppQaDriverObservedPoll | undefined { "pollCreationMessage", "pollCreationMessageV2", "pollCreationMessageV3", + "pollCreationMessageV5", ]); if (!poll) { return undefined; @@ -244,6 +236,7 @@ function readMedia(message: unknown): const mediaSections = [ "imageMessage", "videoMessage", + "ptvMessage", "audioMessage", "documentMessage", "stickerMessage", diff --git a/packages/gateway-protocol/src/schema/frames.ts b/packages/gateway-protocol/src/schema/frames.ts index 3ff0cd70baae..9ec1f5c0f646 100644 --- a/packages/gateway-protocol/src/schema/frames.ts +++ b/packages/gateway-protocol/src/schema/frames.ts @@ -138,6 +138,17 @@ export const HelloOkSchema = closedObject({ maxPayload: Type.Integer({ minimum: 1 }), maxBufferedBytes: Type.Integer({ minimum: 1 }), tickIntervalMs: Type.Integer({ minimum: 1 }), + // Additive: unconditional decoded-size ceilings for chat attachments, so + // clients can validate a file before sending instead of hardcoding guesses. + // Per attachment, not per frame: the encoded request must still fit + // `maxPayload`. MIME acceptance and per-message counts stay server-side + // because they depend on the entrypoint, resolved model, and payload sniffing. + attachments: Type.Optional( + closedObject({ + maxBytes: Type.Integer({ minimum: 1 }), + maxImageBytes: Type.Integer({ minimum: 1 }), + }), + ), allowedSessionVisibilities: Type.Optional(Type.Array(SessionVisibilitySchema)), hasMultipleSessionSharingIdentities: Type.Optional(Type.Boolean()), }), diff --git a/scripts/bench-cli-startup.ts b/scripts/bench-cli-startup.ts index affb00029acd..349849d8b149 100644 --- a/scripts/bench-cli-startup.ts +++ b/scripts/bench-cli-startup.ts @@ -12,6 +12,7 @@ type CommandCase = { name: string; args: string[]; presets: readonly string[]; + stateScope?: "case" | "sample"; expectedExitCodes?: readonly number[]; expectedNonzeroOutputIncludes?: readonly string[]; firstOutputBudgetMs?: number; @@ -444,6 +445,19 @@ const COMMAND_CASES: readonly CommandCase[] = [ expectedExitCodes: [0, 1], expectedNonzeroOutputIncludes: ['"ok"', '"gateway_transport_error"'], }, + { + id: "gatewayHealthJsonConnected", + name: "gateway health --json (connected)", + args: ["gateway", "health", "--json"], + presets: [], + stateScope: "case", + }, + { + id: "gatewayHealthJsonFirstDevice", + name: "gateway health --json (first device)", + args: ["gateway", "health", "--json"], + presets: [], + }, { id: "configGetGatewayPort", name: "config get gateway.port", @@ -649,6 +663,8 @@ function buildConfigFixture(commandCase: CommandCase): Record | if ( commandCase.id !== "configGetGatewayPort" && commandCase.id !== "gatewayHealthJson" && + commandCase.id !== "gatewayHealthJsonConnected" && + commandCase.id !== "gatewayHealthJsonFirstDevice" && commandCase.id !== "health" && commandCase.id !== "healthJson" ) { @@ -717,8 +733,10 @@ async function runSample(params: { cpuProfDir?: string; heapProfDir?: string; rssHookPath: string; + runRoot?: string; }): Promise { - const runRoot = mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")); + const runRoot = params.runRoot ?? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")); + const ownsRunRoot = params.runRoot == null; const stateDir = path.join(runRoot, ".openclaw"); const configPath = path.join(stateDir, "openclaw.json"); const configFixture = buildConfigFixture(params.commandCase); @@ -849,7 +867,9 @@ async function runSample(params: { }); }); } finally { - rmSync(runRoot, { recursive: true, force: true }); + if (ownsRunRoot) { + rmSync(runRoot, { recursive: true, force: true }); + } } } @@ -939,14 +959,24 @@ async function runCase(params: { }): Promise { const samples: Sample[] = []; const totalRuns = params.warmup + params.runs; - for (let i = 0; i < totalRuns; i += 1) { - const sample = await runSample(params); - if (i < params.warmup) { - continue; + const caseRunRoot = + params.commandCase.stateScope === "case" + ? mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-bench-home-")) + : undefined; + try { + for (let i = 0; i < totalRuns; i += 1) { + const sample = await runSample({ ...params, runRoot: caseRunRoot }); + if (i < params.warmup) { + continue; + } + samples.push(sample); + } + return samples; + } finally { + if (caseRunRoot) { + rmSync(caseRunRoot, { recursive: true, force: true }); } - samples.push(sample); } - return samples; } function tailLines(value: string, maxLines: number): string { diff --git a/scripts/lib/state-schema-inline-plugin.d.mts b/scripts/lib/state-schema-inline-plugin.d.mts new file mode 100644 index 000000000000..329eaf70ac1a --- /dev/null +++ b/scripts/lib/state-schema-inline-plugin.d.mts @@ -0,0 +1,9 @@ +export const STATE_SCHEMA_INLINE_PLUGIN_NAME: string; + +export function createStateSchemaInlinePlugin(rootDir?: string): { + name: string; + load( + this: { addWatchFile(id: string): void }, + id: string, + ): { code: string; moduleType: "js" } | null; +}; diff --git a/scripts/lib/state-schema-inline-plugin.mjs b/scripts/lib/state-schema-inline-plugin.mjs new file mode 100644 index 000000000000..fb48b55d8f0d --- /dev/null +++ b/scripts/lib/state-schema-inline-plugin.mjs @@ -0,0 +1,40 @@ +import fs from "node:fs"; +import path from "node:path"; + +export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas"; + +const STATE_SCHEMA_MODULES = [ + { + modulePath: "src/state/openclaw-state-schema.ts", + schemaPath: "src/state/openclaw-state-schema.sql", + exportName: "OPENCLAW_STATE_SCHEMA_SQL", + }, + { + modulePath: "src/state/openclaw-agent-schema.ts", + schemaPath: "src/state/openclaw-agent-schema.sql", + exportName: "OPENCLAW_AGENT_SCHEMA_SQL", + }, +]; + +/** Inline canonical schema bytes so bundled consumers need no SQL asset. */ +export function createStateSchemaInlinePlugin(rootDir = process.cwd()) { + const schemasByModulePath = new Map( + STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]), + ); + + return { + name: STATE_SCHEMA_INLINE_PLUGIN_NAME, + load(id) { + const schema = schemasByModulePath.get(path.resolve(id)); + if (!schema) { + return null; + } + const schemaPath = path.resolve(rootDir, schema.schemaPath); + this.addWatchFile(schemaPath); + return { + code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`, + moduleType: "js", + }; + }, + }; +} diff --git a/scripts/test-projects.test-support.mjs b/scripts/test-projects.test-support.mjs index 4f90fd44f76a..b0b8d7ed1aa7 100644 --- a/scripts/test-projects.test-support.mjs +++ b/scripts/test-projects.test-support.mjs @@ -1986,6 +1986,7 @@ const EXACT_TOOLING_TARGETS = new Map([ ], ], ["scripts/run-vitest.mjs", ["run-vitest", "test-projects", "vitest-local-scheduling"]], + ["scripts/run-oxlint-shards.mjs", ["run-oxlint"]], ["scripts/docker-e2e-rerun.mjs", ["docker-e2e-helper-cli"]], ["scripts/openclaw-postpack.mjs", [TOOLING_VITEST_CONFIG]], ["scripts/openclaw-npm-prepublish-verify.ts", ["test/openclaw-npm-prepublish-verify.test.ts"]], diff --git a/src/agents/agent-command-recovery-owner.ts b/src/agents/agent-command-recovery-owner.ts index 7ffd52f34f62..26f27e140c0d 100644 --- a/src/agents/agent-command-recovery-owner.ts +++ b/src/agents/agent-command-recovery-owner.ts @@ -3,16 +3,12 @@ import type { InternalSessionEntry } from "../config/sessions.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { AgentCommandOpts } from "./command/types.js"; +import { repairMainSessionRecoveryMutation } from "./main-session-recovery-lifecycle.js"; import { scheduleMainSessionRecoveryPendingTarget } from "./main-session-recovery-owner-release.js"; import { - restoreAdmittedRecoveryWithRetries, - scheduleAdmittedRecoveryRestore, -} from "./main-session-recovery-restore.js"; -import { - bindMainSessionRecoveryOwnerRun, claimMainSessionRecoveryOwner, inspectMainSessionRecoveryRequired, - readMainSessionRecoveryOwner, + refreshMainSessionRecoveryOwner, releaseMainSessionRecoveryOwner, type MainSessionRecoveryOwnerLease, type MainSessionRecoveryPendingTarget, @@ -85,14 +81,11 @@ async function claimAgentCommandRecoveryOwner(params: { // session resolution so rollover or rerouting cannot execute under another row's lease. throw new Error("main-session recovery owner changed during ingress preparation; retry"); } - if (params.opts.runId) { - return await bindMainSessionRecoveryOwnerRun(transferredLease, params.opts.runId); - } - const snapshot = await readMainSessionRecoveryOwner(transferredLease); + const snapshot = await refreshMainSessionRecoveryOwner(transferredLease, params.opts.runId); if (!snapshot) { throw new Error("main-session recovery owner changed during ingress preparation; retry"); } - return { ...snapshot, lease: transferredLease }; + return snapshot; } if (params.opts.sessionEffects === "internal") { return undefined; @@ -167,17 +160,16 @@ export async function runWithAgentCommandRecoveryOwner< } catch (error) { // Gateway admission consumes the durable reservation before command // preparation. Restore it when preparation fails before a run exists. - if (params.restoreAdmittedRecovery) { - try { - pendingRecovery = await restoreAdmittedRecoveryWithRetries( - params.restoreAdmittedRecovery, - ); - } catch (restoreError) { - log.warn( - `failed to restore admitted recovery after command preparation: ${formatErrorMessage(restoreError)}`, - ); - scheduleAdmittedRecoveryRestore(params.restoreAdmittedRecovery); - } + const restoreAdmittedRecovery = params.restoreAdmittedRecovery; + if (restoreAdmittedRecovery) { + pendingRecovery = await repairMainSessionRecoveryMutation({ + mutation: restoreAdmittedRecovery, + onDeferredSuccess: scheduleMainSessionRecoveryPendingTarget, + onError: (restoreError) => + log.warn( + `failed to restore admitted recovery after command preparation: ${formatErrorMessage(restoreError)}`, + ), + }); } throw error; } diff --git a/src/agents/agent-hooks/compaction-instructions.test.ts b/src/agents/agent-hooks/compaction-instructions.test.ts index 67ab15bc003a..75c5545c70b6 100644 --- a/src/agents/agent-hooks/compaction-instructions.test.ts +++ b/src/agents/agent-hooks/compaction-instructions.test.ts @@ -1,9 +1,6 @@ /** Tests compaction instruction defaults, precedence, and split-turn composition. */ import { describe, expect, it } from "vitest"; -import { - resolveCompactionInstructions, - composeSplitTurnInstructions, -} from "./compaction-instructions.js"; +import { resolveCompactionInstructions } from "./compaction-instructions.js"; const DEFAULT_COMPACTION_INSTRUCTIONS = resolveCompactionInstructions(undefined, undefined); @@ -188,56 +185,3 @@ describe("resolveCompactionInstructions", () => { }); }); }); - -describe("composeSplitTurnInstructions", () => { - it("joins turn prefix, separator, and resolved instructions with double newlines", () => { - const result = composeSplitTurnInstructions("Turn prefix here", "Resolved instructions here"); - expect(result).toBe( - "Turn prefix here\n\nAdditional requirements:\n\nResolved instructions here", - ); - }); - - it("output contains the turn prefix verbatim", () => { - const prefix = "Summarize the last 5 messages."; - const result = composeSplitTurnInstructions(prefix, "Keep it short."); - expect(result).toContain(prefix); - }); - - it("output contains the resolved instructions verbatim", () => { - const instructions = "Write in Korean. Preserve persona."; - const result = composeSplitTurnInstructions("prefix", instructions); - expect(result).toContain(instructions); - }); - - it("output contains 'Additional requirements:' separator", () => { - const result = composeSplitTurnInstructions("a", "b"); - expect(result).toContain("Additional requirements:"); - }); - - it("KNOWN_EDGE: empty turnPrefix produces leading blank line", () => { - const result = composeSplitTurnInstructions("", "instructions"); - expect(result).toBe("\n\nAdditional requirements:\n\ninstructions"); - expect(result.startsWith("\n")).toBe(true); - }); - - it("KNOWN_EDGE: empty resolvedInstructions produces trailing blank area", () => { - const result = composeSplitTurnInstructions("prefix", ""); - expect(result).toBe("prefix\n\nAdditional requirements:\n\n"); - expect(result.endsWith("\n\n")).toBe(true); - }); - - it("does not deduplicate if instructions already contain 'Additional requirements:'", () => { - const instructions = "Additional requirements: keep it short."; - const result = composeSplitTurnInstructions("prefix", instructions); - const count = (result.match(/Additional requirements:/g) || []).length; - expect(count).toBe(2); - }); - - it("preserves multiline content in both inputs", () => { - const prefix = "Line 1\nLine 2"; - const instructions = "Rule A\nRule B\nRule C"; - const result = composeSplitTurnInstructions(prefix, instructions); - expect(result).toContain("Line 1\nLine 2"); - expect(result).toContain("Rule A\nRule B\nRule C"); - }); -}); diff --git a/src/agents/agent-hooks/compaction-instructions.ts b/src/agents/agent-hooks/compaction-instructions.ts index 6a1fc736e747..135c951f6e56 100644 --- a/src/agents/agent-hooks/compaction-instructions.ts +++ b/src/agents/agent-hooks/compaction-instructions.ts @@ -4,6 +4,7 @@ * Provides default language-preservation instructions and a precedence-based * resolver for customInstructions used during context compaction summaries. */ +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; /** * Default instructions injected into every safeguard-mode compaction summary. @@ -22,22 +23,6 @@ const DEFAULT_COMPACTION_INSTRUCTIONS = */ const MAX_INSTRUCTION_LENGTH = 800; -function truncateUnicodeSafe(s: string, maxCodePoints: number): string { - const chars = Array.from(s); - if (chars.length <= maxCodePoints) { - return s; - } - return chars.slice(0, maxCodePoints).join(""); -} - -function normalize(s: string | undefined): string | undefined { - if (s == null) { - return undefined; - } - const trimmed = s.trim(); - return trimmed.length > 0 ? trimmed : undefined; -} - /** * Resolve compaction instructions with precedence: * event (SDK) → runtime (config) → DEFAULT constant. @@ -50,19 +35,8 @@ export function resolveCompactionInstructions( runtimeInstructions: string | undefined, ): string { const resolved = - normalize(eventInstructions) ?? - normalize(runtimeInstructions) ?? + normalizeOptionalString(eventInstructions) ?? + normalizeOptionalString(runtimeInstructions) ?? DEFAULT_COMPACTION_INSTRUCTIONS; - return truncateUnicodeSafe(resolved, MAX_INSTRUCTION_LENGTH); -} - -/** - * Compose split-turn instructions by combining the SDK's turn-prefix - * instructions with the resolved compaction instructions. - */ -export function composeSplitTurnInstructions( - turnPrefixInstructions: string, - resolvedInstructions: string, -): string { - return [turnPrefixInstructions, "Additional requirements:", resolvedInstructions].join("\n\n"); + return Array.from(resolved).slice(0, MAX_INSTRUCTION_LENGTH).join(""); } diff --git a/src/agents/agent-hooks/compaction-safeguard-quality.ts b/src/agents/agent-hooks/compaction-safeguard-quality.ts index 0d99dd74910d..3c9d14706b1e 100644 --- a/src/agents/agent-hooks/compaction-safeguard-quality.ts +++ b/src/agents/agent-hooks/compaction-safeguard-quality.ts @@ -94,10 +94,7 @@ function hasRequiredSummarySections(summary: string): boolean { } /** Return a structured fallback summary when model output is missing/invalid. */ -export function buildStructuredFallbackSummary( - previousSummary: string | undefined, - _summarizationInstructions?: CompactionSummarizationInstructions, -): string { +export function buildStructuredFallbackSummary(previousSummary: string | undefined): string { const trimmedPreviousSummary = previousSummary?.trim() ?? ""; if (trimmedPreviousSummary && hasRequiredSummarySections(trimmedPreviousSummary)) { return trimmedPreviousSummary; @@ -153,12 +150,10 @@ export function extractOpaqueIdentifiers(text: string): string[] { text.match( /([A-Fa-f0-9]{8,}|https?:\/\/\S+|\/[\w.-]{2,}(?:\/[\w.-]+)+|[A-Za-z]:\\[\w\\.-]+|[A-Za-z0-9._-]+\.[A-Za-z0-9._/-]+:\d{1,5}|\b\d{6,}\b)/g, ) ?? []; - return Array.from( - new Set( - matches - .map((value) => normalizeOpaqueIdentifier(sanitizeExtractedIdentifier(value))) - .filter((value) => value.length >= 4), - ), + return uniqueStrings( + matches + .map((value) => normalizeOpaqueIdentifier(sanitizeExtractedIdentifier(value))) + .filter((value) => value.length >= 4), ).slice(0, MAX_EXTRACTED_IDENTIFIERS); } diff --git a/src/agents/agent-hooks/compaction-safeguard-runtime.ts b/src/agents/agent-hooks/compaction-safeguard-runtime.ts index 13e393c9d008..539206ee9ed1 100644 --- a/src/agents/agent-hooks/compaction-safeguard-runtime.ts +++ b/src/agents/agent-hooks/compaction-safeguard-runtime.ts @@ -49,14 +49,9 @@ export function setCompactionSafeguardCancelReason( const current = getCompactionSafeguardRuntime(sessionManager); const trimmed = reason?.trim(); - if (!current) { - if (!trimmed) { - return; - } - setCompactionSafeguardRuntime(sessionManager, { cancelReason: trimmed }); + if (!current && !trimmed) { return; } - const next = { ...current }; if (trimmed) { next.cancelReason = trimmed; diff --git a/src/agents/agent-hooks/compaction-safeguard.test.ts b/src/agents/agent-hooks/compaction-safeguard.test.ts index c018126cca3f..ef3e50f25ad8 100644 --- a/src/agents/agent-hooks/compaction-safeguard.test.ts +++ b/src/agents/agent-hooks/compaction-safeguard.test.ts @@ -1439,9 +1439,7 @@ describe("compaction-safeguard recent-turn preservation", () => { }); it("does not force policy-off marker in fallback exact identifiers section", () => { - const summary = buildStructuredFallbackSummary(undefined, { - identifierPolicy: "off", - }); + const summary = buildStructuredFallbackSummary(undefined); expect(summary).toContain("## Exact identifiers"); expect(summary).toContain("None captured."); expect(summary).not.toContain("N/A (identifier policy off)."); @@ -2143,6 +2141,9 @@ describe("compaction-safeguard recent-turn preservation", () => { expect(summary).toContain("latest ask status"); expect(summary).toContain("latest assistant reply"); expect(mockSummarizeInStages).toHaveBeenCalledTimes(3); + expect(requireRecord(mockCallArg(mockSummarizeInStages, 1)).customInstructions).toContain( + "Additional requirements:", + ); }); it("keeps required headings when all turns are preserved and history is carried forward", async () => { @@ -2845,70 +2846,86 @@ describe("compaction-safeguard double-compaction guard", () => { ).toBe(true); }); - it("does not replay inter-session sessions_send branch turns as fallback history", async () => { - mockSummarizeInStages.mockReset(); - mockSummarizeInStages.mockResolvedValue(summaryResult("branch summary")); + it.each([ + { assistantText: "bee reply", completed: true }, + { assistantText: " \t\n ", completed: false }, + ])( + "drops delegated branch turns only after meaningful terminal output ($completed)", + async ({ assistantText, completed }) => { + mockSummarizeInStages.mockReset(); + mockSummarizeInStages.mockResolvedValue(summaryResult("branch summary")); - const now = Date.now(); - const sessionManager = { - ...stubSessionManager(), - getBranch: () => [ - { - type: "message", - id: "user-1", - parentId: null, - timestamp: new Date(now).toISOString(), - message: { - role: "user", - content: "say bee", - provenance: { - kind: "inter_session", - sourceSessionKey: "agent:pm", - sourceTool: "sessions_send", + const now = Date.now(); + const sessionManager = { + ...stubSessionManager(), + getBranch: () => [ + { + type: "message", + id: "user-1", + parentId: null, + timestamp: new Date(now).toISOString(), + message: { + role: "user", + content: "say bee", + provenance: { + kind: "inter_session", + sourceSessionKey: "agent:pm", + sourceTool: "sessions_send", + }, + timestamp: now, }, - timestamp: now, }, - }, - { - type: "message", - id: "assistant-1", - parentId: "user-1", - timestamp: new Date(now + 1).toISOString(), - message: { - role: "assistant", - content: [{ type: "text", text: "bee reply" }], - timestamp: now + 1, + { + type: "message", + id: "assistant-1", + parentId: "user-1", + timestamp: new Date(now + 1).toISOString(), + message: { + role: "assistant", + content: [{ type: "text", text: assistantText }], + timestamp: now + 1, + }, }, + ], + } as ExtensionContext["sessionManager"]; + const model = createAnthropicModelFixture(); + setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); + + const mockEvent = { + preparation: { + messagesToSummarize: [] as AgentMessage[], + turnPrefixMessages: [] as AgentMessage[], + firstKeptEntryId: "entry-7", + tokensBefore: 38085, + fileOps: { read: [], edited: [], written: [] }, + settings: { reserveTokens: 4000 }, + isSplitTurn: true, }, - ], - } as ExtensionContext["sessionManager"]; - const model = createAnthropicModelFixture(); - setCompactionSafeguardRuntime(sessionManager, { model, recentTurnsPreserve: 0 }); + customInstructions: "", + signal: new AbortController().signal, + }; + const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({ + sessionManager, + event: mockEvent, + apiKey: "dummy", + }); - const mockEvent = { - preparation: { - messagesToSummarize: [] as AgentMessage[], - turnPrefixMessages: [] as AgentMessage[], - firstKeptEntryId: "entry-7", - tokensBefore: 38085, - fileOps: { read: [], edited: [], written: [] }, - settings: { reserveTokens: 4000 }, - isSplitTurn: true, - }, - customInstructions: "", - signal: new AbortController().signal, - }; - const { result, getApiKeyAndHeadersMock } = await runCompactionScenario({ - sessionManager, - event: mockEvent, - apiKey: "dummy", - }); - - const compaction = expectCompactionResult(result); - expect(compaction.summary).toContain("No prior history."); - expect(mockSummarizeInStages).not.toHaveBeenCalled(); - expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled(); - }); + const compaction = expectCompactionResult(result); + if (completed) { + expect(compaction.summary).toContain("No prior history."); + expect(mockSummarizeInStages).not.toHaveBeenCalled(); + expect(getApiKeyAndHeadersMock).not.toHaveBeenCalled(); + return; + } + expect(compaction.summary).toContain("branch summary"); + expect(mockSummarizeInStages).toHaveBeenCalledTimes(1); + expect(getApiKeyAndHeadersMock).toHaveBeenCalledTimes(1); + const summarizeCall = requireRecord(mockCallArg(mockSummarizeInStages)); + expect( + requireArray(summarizeCall.messages).map((message) => requireRecord(message).role), + ).toEqual(["user", "assistant"]); + }, + ); it.each([ { toolName: "read", expectedRoles: ["user", "assistant", "toolResult"] }, diff --git a/src/agents/agent-hooks/compaction-safeguard.ts b/src/agents/agent-hooks/compaction-safeguard.ts index 3eb63ff3cd5f..636e1a1ff647 100644 --- a/src/agents/agent-hooks/compaction-safeguard.ts +++ b/src/agents/agent-hooks/compaction-safeguard.ts @@ -2,6 +2,7 @@ import fs from "node:fs"; import path from "node:path"; +import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; import { extractSections } from "../../auto-reply/reply/post-compaction-context.js"; import { isAbortError } from "../../infra/abort-signal.js"; @@ -14,10 +15,8 @@ import { } from "../../plugins/compaction-provider.js"; import { normalizeInputProvenance } from "../../sessions/input-provenance.js"; import { normalizeAcceptedSessionSpawnResult } from "../accepted-session-spawn.js"; -import { - buildHistoryPrunePlanWithWorker, - computeAdaptiveChunkRatioWithWorker, -} from "../compaction-planning-worker.js"; +import { computeAdaptiveChunkRatioWithWorker } from "../compaction-planning-worker.js"; +import { buildHistoryPrunePlan } from "../compaction-planning.js"; import { hasMeaningfulConversationContent, isRealConversationMessage, @@ -44,10 +43,7 @@ import { MAX_WORKSPACE_BOOTSTRAP_FILE_BYTES, readWorkspaceBootstrapFile, } from "../workspace-bootstrap-read.js"; -import { - composeSplitTurnInstructions, - resolveCompactionInstructions, -} from "./compaction-instructions.js"; +import { resolveCompactionInstructions } from "./compaction-instructions.js"; import { appendSummarySection, auditSummaryQuality, @@ -87,19 +83,6 @@ const compactionSafeguardDeps = { summarizeInStages, }; -function buildPreviousSummaryMessage(previousSummary: string): AgentMessage { - return { - role: "user", - content: [ - { - type: "text", - text: `\n${PREVIOUS_SUMMARY_REDISTILL_PREFIX}\n\n${previousSummary.trim()}\n`, - }, - ], - timestamp: 0, - } as AgentMessage; -} - function prependPreviousSummaryForRedistill(params: { messages: AgentMessage[]; previousSummary?: string; @@ -108,27 +91,27 @@ function prependPreviousSummaryForRedistill(params: { if (!previousSummary) { return params.messages; } - return [buildPreviousSummaryMessage(previousSummary), ...params.messages]; + return [ + { + role: "user", + content: [ + { + type: "text", + text: `\n${PREVIOUS_SUMMARY_REDISTILL_PREFIX}\n\n${previousSummary}\n`, + }, + ], + timestamp: 0, + } as AgentMessage, + ...params.messages, + ]; } -type SessionBranchEntry = { - type?: unknown; - message?: unknown; - customType?: unknown; - content?: unknown; - display?: unknown; - details?: unknown; - timestamp?: unknown; - summary?: unknown; - fromId?: unknown; -}; - function coerceTimestamp(value: unknown): number { const timestamp = typeof value === "string" ? Date.parse(value) : value; return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : 0; } -function sessionBranchEntryToMessage(entry: SessionBranchEntry): unknown { +function sessionBranchEntryToMessage(entry: Record): unknown { if (entry.type === "message" && entry.message && typeof entry.message === "object") { return entry.message; } @@ -154,17 +137,13 @@ function sessionBranchEntryToMessage(entry: SessionBranchEntry): unknown { } function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] { - const getBranch = (sessionManager as { getBranch?: unknown })?.getBranch; - if (typeof getBranch !== "function") { - return []; - } try { - const entries: unknown = getBranch.call(sessionManager); + const entries: unknown = (sessionManager as { getBranch?: () => unknown })?.getBranch?.(); return Array.isArray(entries) ? entries.flatMap((entry) => { const message = entry && typeof entry === "object" - ? sessionBranchEntryToMessage(entry as SessionBranchEntry) + ? sessionBranchEntryToMessage(entry as Record) : undefined; return message ? [message as AgentMessage] : []; }) @@ -174,42 +153,27 @@ function collectSessionBranchMessages(sessionManager: unknown): AgentMessage[] { } } -function isReplayUnsafeInterSessionInput(message: AgentMessage): boolean { - if ((message as { role?: unknown }).role !== "user") { - return false; - } - const provenance = normalizeInputProvenance((message as { provenance?: unknown }).provenance); - return provenance?.kind === "inter_session" && provenance.sourceTool === "sessions_send"; -} - function isSessionsSendToolName(value: unknown): boolean { - if (typeof value !== "string") { - return false; - } return ( - value - .trim() - .toLowerCase() + normalizeOptionalString(value) + ?.toLowerCase() .replace(/^(?:functions?|tools?)[./_-]/, "") === "sessions_send" ); } function sanitizeSourceSessionSends(messages: AgentMessage[]): AgentMessage[] { - const sendCallIds = new Set(); + const sendCallIds = new Set( + messages.flatMap((message) => + message.role === "assistant" + ? extractToolCallsFromAssistant(message) + .filter((call) => isSessionsSendToolName(call.name)) + .map((call) => call.id.trim()) + .filter(Boolean) + : [], + ), + ); const resultTextByCallId = new Map(); - for (const message of messages) { - if (message.role !== "assistant") { - continue; - } - for (const call of extractToolCallsFromAssistant(message)) { - const callId = call.id.trim(); - if (callId && isSessionsSendToolName(call.name)) { - sendCallIds.add(callId); - } - } - } - for (const message of messages) { if (message.role !== "toolResult") { continue; @@ -249,14 +213,10 @@ function sanitizeSourceSessionSends(messages: AgentMessage[]): AgentMessage[] { const resultText = callId ? resultTextByCallId.get(callId) : undefined; const resolved = Boolean(callId && resultTextByCallId.has(callId)); const requestText = JSON.stringify({ callId: callId || undefined, args: record.arguments }); + const resultSuffix = resolved ? `\nResult: ${resultText || "[empty]"}` : ""; return { type: "text", - text: - resolved && resultText - ? `sessions_send result received; delivery call omitted from replay.\nRequest: ${requestText}\nResult: ${resultText}` - : resolved - ? `sessions_send result received; delivery call omitted from replay.\nRequest: ${requestText}\nResult: [empty]` - : `sessions_send result missing; delivery call omitted from replay.\nRequest: ${requestText}`, + text: `sessions_send result ${resolved ? "received" : "missing"}; delivery call omitted from replay.\nRequest: ${requestText}${resultSuffix}`, }; }); return replaced ? [{ ...message, content } as AgentMessage] : [message]; @@ -296,6 +256,10 @@ function filterReplayUnsafeSessionBranchMessages(messages: AgentMessage[]): Agen return typeof type === "string" && TOOL_CALL_BLOCK_TYPES.has(type); })); const activeInput = sanitizedMessages[turnStart - 1]; + const activeInputProvenance = + activeInput?.role === "user" + ? normalizeInputProvenance((activeInput as { provenance?: unknown }).provenance) + : undefined; // A completed sessions_send target run is already delivered to its caller. // Require terminal text so compaction after tool output can still recover unfinished work. @@ -303,8 +267,8 @@ function filterReplayUnsafeSessionBranchMessages(messages: AgentMessage[]): Agen endsWithTerminalAssistantText && turnStart < sanitizedMessages.length && turnStart > 0 && - activeInput !== undefined && - isReplayUnsafeInterSessionInput(activeInput) + activeInputProvenance?.kind === "inter_session" && + activeInputProvenance.sourceTool === "sessions_send" ) { return sanitizedMessages.slice(0, turnStart - 1); } @@ -348,16 +312,13 @@ function assembleSuffix(parts: { fileOpsSummary?: string; workspaceContext?: string; }): string { - let suffix = Object.values(parts).reduce( + const suffix = Object.values(parts).reduce( (summary, section) => appendSummarySection(summary, section ?? ""), "", ); // Ensure leading separator so suffix does not merge with body (e.g. when body // ends without newline: "...## Exact identifiers## Tool Failures"). - if (suffix && !/^\s/.test(suffix)) { - suffix = `\n\n${suffix}`; - } - return suffix; + return suffix && !/^\s/.test(suffix) ? `\n\n${suffix}` : suffix; } type ToolFailure = { @@ -447,51 +408,42 @@ function buildCompactionSummaryHeaders(params: { }; } -function clampNonNegativeInt(value: unknown, fallback: number): number { +function clampNonNegativeInt( + value: unknown, + fallback: number, + max = Number.POSITIVE_INFINITY, +): number { const normalized = typeof value === "number" && Number.isFinite(value) ? value : fallback; - return Math.max(0, Math.floor(normalized)); + return Math.min(max, Math.max(0, Math.floor(normalized))); } function resolveRecentTurnsPreserve(value: unknown): number { - return Math.min( - MAX_RECENT_TURNS_PRESERVE, - clampNonNegativeInt(value, DEFAULT_RECENT_TURNS_PRESERVE), - ); + return clampNonNegativeInt(value, DEFAULT_RECENT_TURNS_PRESERVE, MAX_RECENT_TURNS_PRESERVE); } function resolveQualityGuardMaxRetries(value: unknown): number { - return Math.min( + return clampNonNegativeInt( + value, + DEFAULT_QUALITY_GUARD_MAX_RETRIES, MAX_QUALITY_GUARD_MAX_RETRIES, - clampNonNegativeInt(value, DEFAULT_QUALITY_GUARD_MAX_RETRIES), ); } -function normalizeFailureText(text: string): string { - return text.replace(/\s+/g, " ").trim(); -} - -function truncateFailureText(text: string, maxChars: number): string { - if (text.length <= maxChars) { - return text; - } - return `${truncateUtf16Safe(text, Math.max(0, maxChars - 3))}...`; -} - function formatToolFailureMeta(details: unknown): string | undefined { if (!details || typeof details !== "object") { return undefined; } const record = details as Record; - const status = typeof record.status === "string" ? record.status : undefined; - const exitCode = - typeof record.exitCode === "number" && Number.isFinite(record.exitCode) - ? record.exitCode - : undefined; - const parts = [ - status ? `status=${status}` : "", - exitCode !== undefined ? `exitCode=${exitCode}` : "", - ]; - return parts.filter(Boolean).join(" ") || undefined; + return ( + [ + typeof record.status === "string" && record.status ? `status=${record.status}` : "", + typeof record.exitCode === "number" && Number.isFinite(record.exitCode) + ? `exitCode=${record.exitCode}` + : "", + ] + .filter(Boolean) + .join(" ") || undefined + ); } function collectToolFailures(messages: AgentMessage[]): ToolFailure[] { @@ -530,13 +482,14 @@ function collectToolFailures(messages: AgentMessage[]): ToolFailure[] { typeof toolResult.toolName === "string" && toolResult.toolName.trim() ? toolResult.toolName : "tool"; - const rawText = collectTextContentBlocks(toolResult.content).join("\n"); const meta = formatToolFailureMeta(toolResult.details); - const normalized = normalizeFailureText(rawText); - const summary = truncateFailureText( - normalized || (meta ? "failed" : "failed (no output)"), - MAX_TOOL_FAILURE_CHARS, - ); + const failureText = + collectTextContentBlocks(toolResult.content).join("\n").replace(/\s+/g, " ").trim() || + (meta ? "failed" : "failed (no output)"); + const summary = + failureText.length > MAX_TOOL_FAILURE_CHARS + ? `${truncateUtf16Safe(failureText, MAX_TOOL_FAILURE_CHARS - 3)}...` + : failureText; failures.push({ toolCallId, toolName, summary, meta }); } @@ -671,10 +624,7 @@ function extractMessageText(message: AgentMessage): string { } function formatNonTextPlaceholder(content: unknown): string | null { - if (content === null || content === undefined) { - return null; - } - if (typeof content === "string") { + if (content == null || typeof content === "string") { return null; } if (!Array.isArray(content)) { @@ -692,22 +642,21 @@ function formatNonTextPlaceholder(content: unknown): string | null { } typeCounts.set(type, (typeCounts.get(type) ?? 0) + 1); } - if (typeCounts.size === 0) { - return null; - } - const parts = [...typeCounts.entries()].map(([type, count]) => - count > 1 ? `${type} x${count}` : type, - ); - return `[non-text content: ${parts.join(", ")}]`; + return typeCounts.size > 0 + ? `[non-text content: ${Array.from(typeCounts, ([type, count]) => + count > 1 ? `${type} x${count}` : type, + ).join(", ")}]` + : null; } function splitPreservedRecentTurns(params: { messages: AgentMessage[]; recentTurnsPreserve: number; }): { summarizableMessages: AgentMessage[]; preservedMessages: AgentMessage[] } { - const preserveTurns = Math.min( + const preserveTurns = clampNonNegativeInt( + params.recentTurnsPreserve, + 0, MAX_RECENT_TURNS_PRESERVE, - clampNonNegativeInt(params.recentTurnsPreserve, 0), ); if (preserveTurns <= 0) { return { summarizableMessages: params.messages, preservedMessages: [] }; @@ -722,18 +671,13 @@ function splitPreservedRecentTurns(params: { const userIndexes = conversationIndexes.filter( (index) => params.messages[index]?.role === "user", ); - const preservedIndexSet = new Set(); - if (userIndexes.length >= preserveTurns) { - const boundaryStartIndex = userIndexes[userIndexes.length - preserveTurns] ?? -1; - for (const index of conversationIndexes) { - if (index >= boundaryStartIndex) { - preservedIndexSet.add(index); - } - } - } else { - for (const userIndex of userIndexes) { - preservedIndexSet.add(userIndex); - } + const boundaryStartIndex = userIndexes.at(-preserveTurns); + const preservedIndexSet = new Set( + boundaryStartIndex === undefined + ? userIndexes + : conversationIndexes.filter((index) => index >= boundaryStartIndex), + ); + if (boundaryStartIndex === undefined) { for (const index of conversationIndexes.toReversed()) { preservedIndexSet.add(index); if (preservedIndexSet.size >= preserveTurns * 2) { @@ -791,12 +735,12 @@ function formatContextMessages(messages: AgentMessage[]): string[] { } else { return null; } - const text = extractMessageText(message); - const nonTextPlaceholder = formatNonTextPlaceholder( - (message as { content?: unknown }).content, - ); - const rendered = - text && nonTextPlaceholder ? `${text}\n${nonTextPlaceholder}` : text || nonTextPlaceholder; + const rendered = [ + extractMessageText(message), + formatNonTextPlaceholder((message as { content?: unknown }).content), + ] + .filter(Boolean) + .join("\n"); if (!rendered) { return null; } @@ -1107,14 +1051,13 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { let droppedSummary: string | undefined; if (tokensBefore !== undefined) { - const prunePlan = await buildHistoryPrunePlanWithWorker({ + const prunePlan = buildHistoryPrunePlan({ messagesToSummarize, turnPrefixMessages, tokensBefore, contextWindowTokens, maxHistoryShare, parts: 2, - signal, }); const { newContentTokens, maxHistoryTokens, pruned } = prunePlan; @@ -1171,18 +1114,15 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { }); messagesToSummarize = summaryTargetMessages; const preservedTurnsSectionLocal = formatPreservedTurnsSection(preservedRecentMessages); - const latestUserAsk = extractLatestUserAsk([...messagesToSummarize, ...turnPrefixMessages]); - const identifierSeedText = [...messagesToSummarize, ...turnPrefixMessages] - .slice(-10) - .map((message) => extractMessageText(message)) - .filter(Boolean) - .join("\n"); - const identifiers = extractOpaqueIdentifiers(identifierSeedText); + const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; + const latestUserAsk = extractLatestUserAsk(allMessages); + const identifiers = extractOpaqueIdentifiers( + allMessages.slice(-10).map(extractMessageText).filter(Boolean).join("\n"), + ); // Use adaptive chunk ratio based on message sizes, reserving headroom for // the summarization prompt, system prompt, previous summary, and reasoning budget // that generateSummary adds on top of the serialized conversation chunk. - const allMessages = [...messagesToSummarize, ...turnPrefixMessages]; const adaptiveRatio = await computeAdaptiveChunkRatioWithWorker({ messages: allMessages, contextWindow: contextWindowTokens, @@ -1196,7 +1136,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { // incorporates context from pruned messages instead of losing it entirely. const effectivePreviousSummary = droppedSummary ?? preparation.previousSummary; - let summary = ""; let lastHistorySummary = ""; let lastSplitTurnSection = ""; let currentInstructions = structuredInstructions; @@ -1218,7 +1157,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { customInstructions: currentInstructions, previousSummary: effectivePreviousSummary, }) - : buildStructuredFallbackSummary(effectivePreviousSummary, summarizationInstructions); + : buildStructuredFallbackSummary(effectivePreviousSummary); summaryWithoutPreservedTurns = historySummary; if (preparation.isSplitTurn && turnPrefixMessages.length > 0) { @@ -1226,10 +1165,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { ...llmSummaryParams, messages: turnPrefixMessages, maxChunkTokens, - customInstructions: composeSplitTurnInstructions( - TURN_PREFIX_INSTRUCTIONS, - currentInstructions, - ), + customInstructions: `${TURN_PREFIX_INSTRUCTIONS}\n\nAdditional requirements:\n\n${currentInstructions}`, previousSummary: undefined, }); splitTurnSectionLocal = `**Turn Context (split turn):**\n\n${prefixSummary}`; @@ -1247,7 +1183,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { `Compaction safeguard: quality retry failed on attempt ${attempt + 1}; ` + `keeping last successful summary: ${formatErrorMessage(attemptError)}`, ); - summary = lastSuccessfulSummary; break; } throw attemptError; @@ -1260,7 +1195,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { messagesToSummarize.length > 0 || (preparation.isSplitTurn && turnPrefixMessages.length > 0); if (!qualityGuardEnabled || !canRegenerate) { - summary = summaryWithPreservedTurns; break; } const quality = auditSummaryQuality({ @@ -1269,7 +1203,6 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { latestAsk: latestUserAsk, identifierPolicy, }); - summary = summaryWithPreservedTurns; if (quality.ok || attempt >= totalAttempts - 1) { break; } @@ -1288,7 +1221,7 @@ export default function compactionSafeguardExtension(api: ExtensionAPI): void { } // Cap history before suffixes so diagnostics and workspace rules survive. - return await finalizeSummary(lastHistorySummary || summary, { + return await finalizeSummary(lastHistorySummary || lastSuccessfulSummary || "", { splitTurnSection: lastSplitTurnSection, preservedTurnsSection: preservedTurnsSectionLocal, }); diff --git a/src/agents/compaction-planning-projection.ts b/src/agents/compaction-planning-projection.ts index 6825379cd912..2583ab6a93f6 100644 --- a/src/agents/compaction-planning-projection.ts +++ b/src/agents/compaction-planning-projection.ts @@ -126,28 +126,21 @@ function jsonLengthWithin( return length; } -function projectToolArguments( - value: unknown, - budget: ProjectionBudget, -): { value: Record; omittedChars: number; changed: boolean } { +function projectToolArguments(value: unknown, budget: ProjectionBudget): number | undefined { const length = jsonLengthWithin(value, budget.remainingChars); if (length !== undefined) { budget.remainingChars -= length; - return { value: {}, omittedChars: 0, changed: false }; + return undefined; } budget.remainingChars = 0; - return { - value: {}, - // Unmeasurable arguments must force an oversized plan, never understate token pressure. - omittedChars: - jsonLengthWithin(value, MAX_ARGUMENT_ESTIMATE_CHARS) ?? UNMEASURABLE_ARGUMENT_OMITTED_CHARS, - changed: true, - }; + // Unmeasurable arguments must force an oversized plan, never understate token pressure. + return ( + jsonLengthWithin(value, MAX_ARGUMENT_ESTIMATE_CHARS) ?? UNMEASURABLE_ARGUMENT_OMITTED_CHARS + ); } function projectContentBlock( block: unknown, - projectTextContent: boolean, budget: ProjectionBudget, ): { block: unknown; omittedChars: number; changed: boolean } { if (!block || typeof block !== "object") { @@ -162,9 +155,6 @@ function projectContentBlock( changed: true, }; } - if (!projectTextContent) { - return { block, omittedChars: 0, changed: false }; - } const hasText = typeof record.text === "string" && record.text.length > 0; const textIsModelVisible = type === "text" || ((type === "toolResult" || type === "tool_result") && hasText); @@ -172,50 +162,41 @@ function projectContentBlock( (type === "toolResult" || type === "tool_result") && !hasText && typeof record.content === "string"; - const projectedText = typeof record.text === "string" ? projectText(record.text, budget) : null; - const projectedContent = - typeof record.content === "string" ? projectText(record.content, budget) : null; - const projectedThinking = - type === "thinking" && typeof record.thinking === "string" - ? projectText(record.thinking, budget) - : null; - const projectedArguments = - type === "toolCall" ? projectToolArguments(record.arguments, budget) : undefined; - // Tool-call IDs are provider-generated and bounded in practice. Planning never uses them as - // durable keys, so malformed giant IDs do not justify an ID-remapping protocol here. - const hasPlanningIrrelevantSignature = - "textSignature" in record || "thinkingSignature" in record || "thoughtSignature" in record; - if ( - !projectedText && - !projectedContent && - !projectedThinking && - !projectedArguments?.changed && - !hasPlanningIrrelevantSignature - ) { - return { block, omittedChars: 0, changed: false }; - } - const next = { ...record }; + let next: Record | undefined; let omittedChars = 0; - if (projectedText) { - next.text = projectedText.text; - omittedChars += textIsModelVisible ? projectedText.omittedChars : 0; + for (const field of ["text", "content", "thinking"] as const) { + if (field === "thinking" && type !== "thinking") { + continue; + } + const value = record[field]; + const projected = typeof value === "string" ? projectText(value, budget) : null; + if (!projected) { + continue; + } + next ??= { ...record }; + next[field] = projected.text; + const modelVisible = + field === "thinking" || (field === "text" ? textIsModelVisible : contentIsModelVisible); + omittedChars += modelVisible ? projected.omittedChars : 0; } - if (projectedContent) { - next.content = projectedContent.text; - omittedChars += contentIsModelVisible ? projectedContent.omittedChars : 0; + if (type === "toolCall") { + const omittedArguments = projectToolArguments(record.arguments, budget); + if (omittedArguments !== undefined) { + next ??= { ...record }; + next.arguments = {}; + omittedChars += omittedArguments; + } } - if (projectedThinking) { - next.thinking = projectedThinking.text; - omittedChars += projectedThinking.omittedChars; + // Signatures never contribute model-visible compaction text and can dwarf the planning payload. + for (const signature of ["textSignature", "thinkingSignature", "thoughtSignature"]) { + if (signature in record) { + next ??= { ...record }; + delete next[signature]; + } } - if (projectedArguments?.changed) { - next.arguments = projectedArguments.value; - omittedChars += projectedArguments.omittedChars; - } - delete next.textSignature; - delete next.thinkingSignature; - delete next.thoughtSignature; - return { block: next, omittedChars, changed: true }; + return next + ? { block: next, omittedChars, changed: true } + : { block, omittedChars, changed: false }; } function projectStringFields( @@ -245,43 +226,24 @@ function projectStringFields( } function projectMessage(message: AgentMessage, budget: ProjectionBudget): AgentMessage { - const source = (() => { - switch (message.role) { - case "assistant": - return { - role: message.role, - content: message.content, - stopReason: message.stopReason, - timestamp: message.timestamp, - } as AgentMessage; - case "bashExecution": { - const { fullOutputPath: _, ...rest } = message; - return rest as AgentMessage; - } - case "compactionSummary": { - const { details: _, ...rest } = message; - return rest as AgentMessage; - } - case "custom": { - const { details: _, ...rest } = message; - return rest as AgentMessage; - } - default: - return message; - } - })(); - const currentOmittedChars = readCompactionPlanningOmittedChars(source); + let source = message; + if (message.role === "assistant") { + source = { + role: message.role, + content: message.content, + stopReason: message.stopReason, + timestamp: message.timestamp, + } as AgentMessage; + } else if (message.role === "bashExecution") { + const { fullOutputPath: _, ...rest } = message; + source = rest as AgentMessage; + } else if (message.role === "compactionSummary" || message.role === "custom") { + const { details: _, ...rest } = message; + source = rest as AgentMessage; + } const content = (source as { content?: unknown }).content; if (typeof content === "string") { - const projected = projectText(content, budget); - if (!projected) { - return source; - } - return { - ...(source as unknown as Record), - content: projected.text, - [OMITTED_CHARS_FIELD]: currentOmittedChars + projected.omittedChars, - } as unknown as AgentMessage; + return projectStringFields(source, ["content"], budget); } if (!Array.isArray(content)) { switch (source.role) { @@ -298,7 +260,7 @@ function projectMessage(message: AgentMessage, budget: ProjectionBudget): AgentM let omittedChars = 0; let changed = false; const projectedContent = content.map((block) => { - const projected = projectContentBlock(block, true, budget); + const projected = projectContentBlock(block, budget); omittedChars += projected.omittedChars; changed ||= projected.changed; return projected.block; @@ -309,7 +271,7 @@ function projectMessage(message: AgentMessage, budget: ProjectionBudget): AgentM return { ...(source as unknown as Record), content: projectedContent, - [OMITTED_CHARS_FIELD]: currentOmittedChars + omittedChars, + [OMITTED_CHARS_FIELD]: readCompactionPlanningOmittedChars(source) + omittedChars, } as unknown as AgentMessage; } diff --git a/src/agents/compaction-planning-worker.ts b/src/agents/compaction-planning-worker.ts index b80b28ae5ed0..ed139bcffc55 100644 --- a/src/agents/compaction-planning-worker.ts +++ b/src/agents/compaction-planning-worker.ts @@ -8,14 +8,12 @@ import { Worker } from "node:worker_threads"; import { resolveTimerTimeoutMs } from "@openclaw/normalization-core/number-coercion"; import { toErrorObject } from "../infra/errors.js"; import { - buildHistoryPrunePlan, buildOversizedFallbackPlan, buildStageSplitPlan, buildSummaryChunks, computeAdaptiveChunkRatio, projectCompactionMessagesForPlanning, sanitizeCompactionMessages, - type HistoryPrunePlan, type OversizedFallbackPlan, type StageSplitPlan, } from "./compaction-planning.js"; @@ -60,13 +58,13 @@ function runCompactionPlanningWorker(params: { timeoutMs?: number; workerUrl?: URL; }): Promise { - if (params.signal?.aborted) { - return Promise.reject( - toErrorObject( - params.signal.reason ?? new Error("compaction planning aborted"), - "Non-Error rejection", - ), + const abortError = () => + toErrorObject( + params.signal?.reason ?? new Error("compaction planning aborted"), + "Non-Error rejection", ); + if (params.signal?.aborted) { + return Promise.reject(abortError()); } const workerUrl = params.workerUrl ?? resolveCompactionPlanningWorkerUrl(); @@ -91,30 +89,11 @@ function runCompactionPlanningWorker(params: { return new Promise((resolve, reject) => { let settled = false; const timeout = setTimeout( - () => { - settle( - () => - reject( - new CompactionPlanningWorkerError("compaction planning worker timed out", "timeout"), - ), - true, - ); - }, + () => + fail(new CompactionPlanningWorkerError("compaction planning worker timed out", "timeout")), resolveTimerTimeoutMs(params.timeoutMs, COMPACTION_PLANNING_WORKER_TIMEOUT_MS), ); - - const abort = () => { - settle( - () => - reject( - toErrorObject( - params.signal?.reason ?? new Error("compaction planning aborted"), - "Non-Error rejection", - ), - ), - true, - ); - }; + const abort = () => fail(abortError()); const settle = (finish: () => void, terminate: boolean) => { if (settled) { @@ -129,6 +108,7 @@ function runCompactionPlanningWorker(params: { } finish(); }; + const fail = (error: Error, terminate = true) => settle(() => reject(error), terminate); params.signal?.addEventListener("abort", abort, { once: true }); @@ -143,20 +123,17 @@ function runCompactionPlanningWorker(params: { }); worker.once("error", (error) => { const message = error instanceof Error ? error.message : String(error); - settle(() => reject(new CompactionPlanningWorkerError(message, "unavailable")), true); + fail(new CompactionPlanningWorkerError(message, "unavailable")); }); worker.once("exit", (code) => { if (code === 0) { return; } - settle( - () => - reject( - new CompactionPlanningWorkerError( - `compaction planning worker exited with code ${code}`, - "unavailable", - ), - ), + fail( + new CompactionPlanningWorkerError( + `compaction planning worker exited with code ${code}`, + "unavailable", + ), false, ); }); @@ -222,14 +199,11 @@ export async function buildSummaryChunksWithWorker(params: { maxChunkTokens: number; signal?: AbortSignal; }): Promise { + const { signal, ...planningInput } = params; return runCompactionPlan({ - input: { - kind: "summaryChunks", - messages: params.messages, - maxChunkTokens: params.maxChunkTokens, - }, - signal: params.signal, - fallback: (messages) => buildSummaryChunks({ messages, maxChunkTokens: params.maxChunkTokens }), + input: { kind: "summaryChunks", ...planningInput }, + signal, + fallback: (messages) => buildSummaryChunks({ ...planningInput, messages }), restore: (value, messages) => value.chunkIndexes.map((indexes) => restoreIndexedMessages(messages, indexes)), }); @@ -241,15 +215,11 @@ export async function buildOversizedFallbackPlanWithWorker(params: { contextWindow: number; signal?: AbortSignal; }): Promise { + const { signal, ...planningInput } = params; return runCompactionPlan({ - input: { - kind: "oversizedFallback", - messages: params.messages, - contextWindow: params.contextWindow, - }, - signal: params.signal, - fallback: (messages) => - buildOversizedFallbackPlan({ messages, contextWindow: params.contextWindow }), + input: { kind: "oversizedFallback", ...planningInput }, + signal, + fallback: (messages) => buildOversizedFallbackPlan({ ...planningInput, messages }), restore: (value, messages) => ({ smallMessages: restoreIndexedMessages(messages, value.smallMessageIndexes), oversizedNotes: value.oversizedNotes, @@ -265,22 +235,11 @@ export async function buildStageSplitPlanWithWorker(params: { minMessagesForSplit?: number; signal?: AbortSignal; }): Promise { + const { signal, ...planningInput } = params; return runCompactionPlan({ - input: { - kind: "stageSplit", - messages: params.messages, - maxChunkTokens: params.maxChunkTokens, - parts: params.parts, - minMessagesForSplit: params.minMessagesForSplit, - }, - signal: params.signal, - fallback: (messages) => - buildStageSplitPlan({ - messages, - maxChunkTokens: params.maxChunkTokens, - parts: params.parts, - minMessagesForSplit: params.minMessagesForSplit, - }), + input: { kind: "stageSplit", ...planningInput }, + signal, + fallback: (messages) => buildStageSplitPlan({ ...planningInput, messages }), restore: (value, messages) => value.mode === "split" ? { @@ -291,38 +250,17 @@ export async function buildStageSplitPlanWithWorker(params: { }); } -/** - * Builds a history-pruning plan on the owner thread. - * - * Pruning repairs tool-result pairs and returns exact retained/dropped messages, - * so a bounded selection projection cannot reconstruct every result faithfully. - */ -export async function buildHistoryPrunePlanWithWorker(params: { - messagesToSummarize: AgentMessage[]; - turnPrefixMessages: AgentMessage[]; - tokensBefore: number; - contextWindowTokens: number; - maxHistoryShare: number; - parts?: number; - signal?: AbortSignal; -}): Promise { - return buildHistoryPrunePlan(params); -} - /** Computes the adaptive compaction chunk ratio with worker fallback. */ export async function computeAdaptiveChunkRatioWithWorker(params: { messages: AgentMessage[]; contextWindow: number; signal?: AbortSignal; }): Promise { + const { signal, ...planningInput } = params; return runCompactionPlan({ - input: { - kind: "adaptiveChunkRatio", - messages: params.messages, - contextWindow: params.contextWindow, - }, - signal: params.signal, - fallback: () => computeAdaptiveChunkRatio(params.messages, params.contextWindow), + input: { kind: "adaptiveChunkRatio", ...planningInput }, + signal, + fallback: () => computeAdaptiveChunkRatio(planningInput.messages, planningInput.contextWindow), restore: (value) => value.ratio, }); } @@ -330,7 +268,6 @@ export async function computeAdaptiveChunkRatioWithWorker(params: { const compactionPlanningWorkerTesting = { resolveCompactionPlanningWorkerUrl, runCompactionPlanningWorker, - CompactionPlanningWorkerError, }; if (process.env.VITEST || process.env.NODE_ENV === "test") { diff --git a/src/agents/compaction-planning.ts b/src/agents/compaction-planning.ts index a2c80d188788..99f43f1c43ea 100644 --- a/src/agents/compaction-planning.ts +++ b/src/agents/compaction-planning.ts @@ -44,7 +44,7 @@ export type OversizedFallbackPlan = { }; /** Token accounting and optional prune result for preserving context-window headroom. */ -export type HistoryPrunePlan = { +type HistoryPrunePlan = { summarizableTokens: number; newContentTokens: number; maxHistoryTokens: number; @@ -80,11 +80,7 @@ export function sanitizeCompactionMessages(messages: AgentMessage[]): AgentMessa } function estimateCompactionPlanningTokens(message: AgentMessage): number { - const omittedChars = readCompactionPlanningOmittedChars(message); - if (omittedChars === 0) { - return estimateTokens(message); - } - return estimateTokens(message) + Math.ceil(omittedChars / 4); + return estimateTokens(message) + Math.ceil(readCompactionPlanningOmittedChars(message) / 4); } /** Builds a bounded planning projection that preserves token pressure accounting. */ @@ -115,23 +111,9 @@ function groupCompactionMessages( let currentTokens = 0; let pendingToolCallIds = new Set(); - const finishCurrentGroup = () => { - if (current.length === 0) { - return; - } - groups.push({ messages: current, tokens: currentTokens }); - current = []; - currentTokens = 0; - }; - for (const [index, message] of messages.entries()) { - const messageTokens = perMessageTokens.at(index); - if (messageTokens === undefined) { - throw new Error("Compaction token estimates are out of sync with messages"); - } - current.push(message); - currentTokens += messageTokens; + currentTokens += perMessageTokens[index]!; if (message.role === "assistant") { const stopReason = (message as { stopReason?: unknown }).stopReason; @@ -152,41 +134,34 @@ function groupCompactionMessages( // A displaced user turn still belongs to an unfinished call/result batch; // splitting it would make one of the resulting provider transcripts invalid. if (pendingToolCallIds.size === 0) { - finishCurrentGroup(); + groups.push({ messages: current, tokens: currentTokens }); + current = []; + currentTokens = 0; } } - finishCurrentGroup(); + if (current.length > 0) { + groups.push({ messages: current, tokens: currentTokens }); + } return groups; } -/** Splits messages into roughly equal token-share chunks without separating active tool pairs. */ -function splitMessagesByTokenShare( +/** Chunks atomic tool-call groups without splitting a provider-visible call/result pair. */ +function chunkCompactionMessageGroups( messages: AgentMessage[], - parts = DEFAULT_PARTS, + maxTokens: number, + perMessageTokens: number[], + maxChunks = Number.POSITIVE_INFINITY, ): AgentMessage[][] { - if (messages.length === 0) { - return []; - } - const normalizedParts = normalizeCompactionParts(parts, messages.length); - if (normalizedParts <= 1) { - return [messages]; - } - - // Sanitize the full array once and reuse per-message token counts; avoids the - // per-message [msg] wrap-and-clone that previously ran on every iteration. - const perMessageTokens = estimatePerMessageTokens(messages); - const totalTokens = perMessageTokens.reduce((sum, tokens) => sum + tokens, 0); - const targetTokens = totalTokens / normalizedParts; const chunks: AgentMessage[][] = []; let current: AgentMessage[] = []; let currentTokens = 0; for (const group of groupCompactionMessages(messages, perMessageTokens)) { if ( - chunks.length < normalizedParts - 1 && current.length > 0 && - currentTokens + group.tokens > targetTokens + chunks.length < maxChunks - 1 && + currentTokens + group.tokens > maxTokens ) { chunks.push(current); current = []; @@ -204,47 +179,26 @@ function splitMessagesByTokenShare( return chunks; } -/** Chunks messages by a max-token budget while applying the shared estimator safety margin. */ -function chunkMessagesByMaxTokens(messages: AgentMessage[], maxTokens: number): AgentMessage[][] { +/** Splits messages into roughly equal token-share chunks without separating active tool pairs. */ +function splitMessagesByTokenShare( + messages: AgentMessage[], + parts = DEFAULT_PARTS, +): AgentMessage[][] { if (messages.length === 0) { return []; } - - // Apply safety margin to compensate for estimateTokens() underestimation - // (chars/4 heuristic misses multi-byte chars, special tokens, code tokens, etc.) - const effectiveMax = Math.max(1, Math.floor(maxTokens / SAFETY_MARGIN)); - - // Sanitize the full array once and reuse per-message token counts; avoids the - // per-message [msg] wrap-and-clone that previously ran on every iteration. + const normalizedParts = normalizeCompactionParts(parts, messages.length); + if (normalizedParts <= 1) { + return [messages]; + } const perMessageTokens = estimatePerMessageTokens(messages); - const chunks: AgentMessage[][] = []; - let currentChunk: AgentMessage[] = []; - let currentTokens = 0; - - for (const group of groupCompactionMessages(messages, perMessageTokens)) { - if (currentChunk.length > 0 && currentTokens + group.tokens > effectiveMax) { - chunks.push(currentChunk); - currentChunk = []; - currentTokens = 0; - } - - currentChunk.push(...group.messages); - currentTokens += group.tokens; - - if (group.tokens > effectiveMax) { - // A tool batch is indivisible even above the heuristic budget; the - // oversized-summary fallback can handle it without orphaning its result. - chunks.push(currentChunk); - currentChunk = []; - currentTokens = 0; - } - } - - if (currentChunk.length > 0) { - chunks.push(currentChunk); - } - - return chunks; + const totalTokens = perMessageTokens.reduce((sum, tokens) => sum + tokens, 0); + return chunkCompactionMessageGroups( + messages, + totalTokens / normalizedParts, + perMessageTokens, + normalizedParts, + ); } /** @@ -256,12 +210,8 @@ export function computeAdaptiveChunkRatio(messages: AgentMessage[], contextWindo return BASE_CHUNK_RATIO; } - const totalTokens = estimateMessagesTokens(messages); - const avgTokens = totalTokens / messages.length; - - // Apply safety margin to account for estimation inaccuracy - const safeAvgTokens = avgTokens * SAFETY_MARGIN; - const avgRatio = safeAvgTokens / contextWindow; + const avgRatio = + ((estimateMessagesTokens(messages) / messages.length) * SAFETY_MARGIN) / contextWindow; // If average message is > 10% of context, reduce chunk ratio if (avgRatio > 0.1) { @@ -285,7 +235,13 @@ export function buildSummaryChunks(params: { }): AgentMessage[][] { // SECURITY: never feed toolResult.details or runtime-context transcript entries into summarization prompts. const safeMessages = sanitizeCompactionMessages(params.messages); - return chunkMessagesByMaxTokens(safeMessages, params.maxChunkTokens); + // The estimator can undercount Unicode/code tokens; indivisible tool batches may exceed this cap. + const effectiveMax = Math.max(1, Math.floor(params.maxChunkTokens / SAFETY_MARGIN)); + return chunkCompactionMessageGroups( + safeMessages, + effectiveMax, + estimatePerMessageTokens(safeMessages), + ); } /** Separates messages too large to summarize and emits compact placeholder notes for them. */ @@ -355,9 +311,8 @@ export function buildStageSplitPlan(params: { function pruneHistoryForContextShare(params: { messages: AgentMessage[]; maxContextTokens: number; - maxHistoryShare?: number; + maxHistoryShare: number; parts?: number; - mode?: "share" | "handoff"; }): { messages: AgentMessage[]; droppedMessagesList: AgentMessage[]; @@ -367,10 +322,7 @@ function pruneHistoryForContextShare(params: { keptTokens: number; budgetTokens: number; } { - const isHandoff = params.mode === "handoff"; - const defaultShare = isHandoff ? 0.2 : 0.5; // Stricter budget for handoff snapshots - const maxHistoryShare = params.maxHistoryShare ?? defaultShare; - const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * maxHistoryShare)); + const budgetTokens = Math.max(1, Math.floor(params.maxContextTokens * params.maxHistoryShare)); let keptMessages = params.messages; const allDroppedMessages: AgentMessage[] = []; let droppedChunks = 0; @@ -384,31 +336,15 @@ function pruneHistoryForContextShare(params: { if (chunks.length <= 1) { break; } - const [dropped, ...rest] = chunks; - if (!dropped) { - break; - } - const flatRest = rest.flat(); - - // After dropping a chunk, repair tool_use/tool_result pairing to handle - // orphaned tool_results (whose tool_use was in the dropped chunk). - // repairToolUseResultPairing drops orphaned tool_results, preventing - // "unexpected tool_use_id" errors from Anthropic's API. - const repairReport = repairToolUseResultPairing(flatRest); - const repairedKept = repairReport.messages; - - // Track orphaned tool_results as dropped (they were in kept but their tool_use was dropped) - const orphanedCount = repairReport.droppedOrphanCount; + const dropped = chunks[0]!; + // Dropping a call owner also drops orphaned results; providers reject replay without the pair. + const repairReport = repairToolUseResultPairing(chunks.slice(1).flat()); droppedChunks += 1; - droppedMessages += dropped.length + orphanedCount; + droppedMessages += dropped.length + repairReport.droppedOrphanCount; droppedTokens += estimateMessagesTokens(dropped); - // Note: We don't have the actual orphaned messages to add to droppedMessagesList - // since repairToolUseResultPairing doesn't return them. This is acceptable since - // the dropped messages are used for summarization, and orphaned tool_results - // without their tool_use context aren't useful for summarization anyway. allDroppedMessages.push(...dropped); - keptMessages = repairedKept; + keptMessages = repairReport.messages; } return { @@ -440,23 +376,16 @@ export function buildHistoryPrunePlan(params: { params.contextWindowTokens * params.maxHistoryShare * SAFETY_MARGIN, ); - if (newContentTokens <= maxHistoryTokens) { - return { - summarizableTokens, - newContentTokens, - maxHistoryTokens, - }; - } - - return { - summarizableTokens, - newContentTokens, - maxHistoryTokens, - pruned: pruneHistoryForContextShare({ - messages: params.messagesToSummarize, - maxContextTokens: params.contextWindowTokens, - maxHistoryShare: params.maxHistoryShare, - parts: params.parts, - }), - }; + const plan = { summarizableTokens, newContentTokens, maxHistoryTokens }; + return newContentTokens <= maxHistoryTokens + ? plan + : { + ...plan, + pruned: pruneHistoryForContextShare({ + messages: params.messagesToSummarize, + maxContextTokens: params.contextWindowTokens, + maxHistoryShare: params.maxHistoryShare, + parts: params.parts, + }), + }; } diff --git a/src/agents/compaction-planning.worker.ts b/src/agents/compaction-planning.worker.ts index e3867d5cc0d0..ed4d9606fc67 100644 --- a/src/agents/compaction-planning.worker.ts +++ b/src/agents/compaction-planning.worker.ts @@ -12,23 +12,9 @@ import type { AgentMessage } from "./runtime/index.js"; /** Serializable request accepted by the compaction planning worker. */ export type CompactionPlanningWorkerInput = - | { - kind: "summaryChunks"; - messages: AgentMessage[]; - maxChunkTokens: number; - } - | { - kind: "oversizedFallback"; - messages: AgentMessage[]; - contextWindow: number; - } - | { - kind: "stageSplit"; - messages: AgentMessage[]; - maxChunkTokens: number; - parts?: number; - minMessagesForSplit?: number; - } + | ({ kind: "summaryChunks" } & Parameters[0]) + | ({ kind: "oversizedFallback" } & Parameters[0]) + | ({ kind: "stageSplit" } & Parameters[0]) | { kind: "adaptiveChunkRatio"; messages: AgentMessage[]; @@ -46,15 +32,7 @@ export type CompactionPlanningWorkerValue = smallMessageIndexes: number[]; oversizedNotes: string[]; } - | { - kind: "stageSplit"; - mode: "single"; - } - | { - kind: "stageSplit"; - mode: "split"; - chunkIndexes: number[][]; - } + | ({ kind: "stageSplit" } & ({ mode: "single" } | { mode: "split"; chunkIndexes: number[][] })) | { kind: "adaptiveChunkRatio"; ratio: number; @@ -142,19 +120,13 @@ function planCompactionWorkerInput( /** Run one compaction planning request and return a serializable result. */ export function runCompactionPlanningWorkerInput(input: unknown): CompactionPlanningWorkerResult { if (!isWorkerInput(input)) { - return { - status: "failed", - error: "invalid compaction planning worker input", - }; + return { status: "failed", error: "invalid compaction planning worker input" }; } try { return { status: "ok", value: planCompactionWorkerInput(input) }; } catch (error) { - return { - status: "failed", - error: error instanceof Error ? error.message : String(error), - }; + return { status: "failed", error: error instanceof Error ? error.message : String(error) }; } } diff --git a/src/agents/compaction-real-conversation.ts b/src/agents/compaction-real-conversation.ts index edadf01cb876..c2e9cd162f89 100644 --- a/src/agents/compaction-real-conversation.ts +++ b/src/agents/compaction-real-conversation.ts @@ -17,17 +17,11 @@ const NON_CONVERSATION_BLOCK_TYPES = new Set([ function hasMeaningfulText(text: string): boolean { const trimmed = text.trim(); - if (!trimmed) { - return false; - } - if (isSilentReplyText(trimmed)) { + if (!trimmed || isSilentReplyText(trimmed)) { return false; } const heartbeat = stripHeartbeatToken(trimmed, { mode: "message" }); - if (heartbeat.didStrip) { - return heartbeat.text.trim().length > 0; - } - return true; + return !heartbeat.didStrip || heartbeat.text.trim().length > 0; } function isSummaryRole(role: unknown): boolean { @@ -73,22 +67,15 @@ function hasMeaningfulMessageContent(content: unknown): boolean { if (!block || typeof block !== "object") { continue; } - const type = (block as { type?: unknown }).type; - if (type !== "text") { + const { type, text } = block as { type?: unknown; text?: unknown }; + if (type === "text") { + if (typeof text === "string" && hasMeaningfulText(text)) { + return true; + } + } else if (typeof type !== "string" || !NON_CONVERSATION_BLOCK_TYPES.has(type)) { // Tool-call metadata and internal reasoning blocks do not make a // heartbeat-only transcript count as real conversation. - if (typeof type === "string" && NON_CONVERSATION_BLOCK_TYPES.has(type)) { - continue; - } sawMeaningfulNonTextBlock = true; - continue; - } - const text = (block as { text?: unknown }).text; - if (typeof text !== "string") { - continue; - } - if (hasMeaningfulText(text)) { - return true; } } return sawMeaningfulNonTextBlock; @@ -123,10 +110,7 @@ export function isRealConversationMessage( const start = Math.max(0, index - TOOL_RESULT_REAL_CONVERSATION_LOOKBACK); for (let i = index - 1; i >= start; i -= 1) { const candidate = messages[i]; - if (!candidate) { - continue; - } - if (isToolResultConversationAnchor(candidate)) { + if (candidate && isToolResultConversationAnchor(candidate)) { return true; } } diff --git a/src/agents/compaction-usage.ts b/src/agents/compaction-usage.ts index 22584ba9d3e1..e4993ce034f2 100644 --- a/src/agents/compaction-usage.ts +++ b/src/agents/compaction-usage.ts @@ -6,16 +6,8 @@ import type { AgentMessage } from "./runtime/index.js"; import { makeZeroUsageSnapshot } from "./usage.js"; function parseCompactionUsageTimestamp(value: unknown): number | null { - if (typeof value === "number" && Number.isFinite(value)) { - return value; - } - if (typeof value === "string") { - const parsed = Date.parse(value); - if (Number.isFinite(parsed)) { - return parsed; - } - } - return null; + const timestamp = typeof value === "string" ? Date.parse(value) : value; + return typeof timestamp === "number" && Number.isFinite(timestamp) ? timestamp : null; } export function stripStaleAssistantUsageBeforeLatestCompaction( @@ -25,56 +17,46 @@ export function stripStaleAssistantUsageBeforeLatestCompaction entry?.role === "compactionSummary", + ); const hasCompactionSummary = latestCompactionSummaryIndex !== -1; if (!hasCompactionSummary && options.whenMissingCompactionSummary !== "zeroAssistantUsage") { return messages; } - const out = options.mutate ? messages : [...messages]; - let touched = false; - for (let i = 0; i < out.length; i += 1) { - const candidate = out[i] as + const latestCompactionTimestamp = parseCompactionUsageTimestamp( + (messages[latestCompactionSummaryIndex] as { timestamp?: unknown } | undefined)?.timestamp, + ); + let out = messages; + for (let i = 0; i < messages.length; i += 1) { + const candidate = messages[i] as | (AgentMessage & { usage?: unknown; timestamp?: unknown }) | undefined; - if (!candidate || candidate.role !== "assistant") { - continue; - } - if (!candidate.usage || typeof candidate.usage !== "object") { + if ( + candidate?.role !== "assistant" || + !candidate.usage || + typeof candidate.usage !== "object" + ) { continue; } const messageTimestamp = parseCompactionUsageTimestamp(candidate.timestamp); - const compactionTimestamp = latestCompactionTimestamp; - const hasTimestampBoundary = - hasCompactionSummary && compactionTimestamp !== null && messageTimestamp !== null; - const staleByMissingSummary = !hasCompactionSummary; - const staleByTimestamp = hasTimestampBoundary && messageTimestamp <= compactionTimestamp; - const staleByLegacyOrdering = - hasCompactionSummary && !hasTimestampBoundary && i < latestCompactionSummaryIndex; - if (!staleByMissingSummary && !staleByTimestamp && !staleByLegacyOrdering) { + const stale = + !hasCompactionSummary || + (latestCompactionTimestamp !== null && messageTimestamp !== null + ? messageTimestamp <= latestCompactionTimestamp + : i < latestCompactionSummaryIndex); + if (!stale) { continue; } // Session runtime expects assistant usage to stay structurally valid during // accounting. Keep stale snapshots present, but zeroed after compaction. - const candidateRecord = candidate as unknown as Record; - out[i] = { - ...candidateRecord, - usage: makeZeroUsageSnapshot(), - } as unknown as TMessage; - touched = true; + if (out === messages && !options.mutate) { + out = [...messages]; + } + out[i] = { ...candidate, usage: makeZeroUsageSnapshot() } as TMessage; } - return touched ? out : messages; + return out; } diff --git a/src/agents/compaction.ts b/src/agents/compaction.ts index 270a711b4390..47d3e30456fb 100644 --- a/src/agents/compaction.ts +++ b/src/agents/compaction.ts @@ -26,7 +26,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "./defaults.js"; import { isTimeoutError } from "./failover-error.js"; import type { AgentMessage, StreamFn, ThinkingLevel } from "./runtime/index.js"; import type { ExtensionContext } from "./sessions/index.js"; -import { generateSummary as agentGenerateSummary } from "./sessions/index.js"; +import { generateSummary } from "./sessions/index.js"; export { BASE_CHUNK_RATIO, @@ -71,18 +71,31 @@ export type CompactionSummarizationInstructions = { identifierInstructions?: string; }; +type CompactionSummaryParams = { + messages: AgentMessage[]; + model: NonNullable; + apiKey: string; + headers?: Record; + signal: AbortSignal; + reserveTokens: number; + maxChunkTokens: number; + contextWindow: number; + customInstructions?: string; + summarizationInstructions?: CompactionSummarizationInstructions; + previousSummary?: string; + thinkingLevel?: ThinkingLevel; + streamFn?: StreamFn; +}; + function resolveIdentifierPreservationInstructions( instructions?: CompactionSummarizationInstructions, ): string | undefined { - const policy = instructions?.identifierPolicy ?? "strict"; - if (policy === "off") { + if (instructions?.identifierPolicy === "off") { return undefined; } - if (policy === "custom") { - const custom = instructions?.identifierInstructions?.trim(); - return custom && custom.length > 0 ? custom : IDENTIFIER_PRESERVATION_INSTRUCTIONS; - } - return IDENTIFIER_PRESERVATION_INSTRUCTIONS; + return instructions?.identifierPolicy === "custom" + ? instructions.identifierInstructions?.trim() || IDENTIFIER_PRESERVATION_INSTRUCTIONS + : IDENTIFIER_PRESERVATION_INSTRUCTIONS; } /** Combines identifier-preservation and caller-provided compaction instructions. */ @@ -92,32 +105,15 @@ function buildCompactionSummarizationInstructions( ): string | undefined { const custom = customInstructions?.trim(); const identifierPreservation = resolveIdentifierPreservationInstructions(instructions); - if (!identifierPreservation && !custom) { - return undefined; - } if (!custom) { return identifierPreservation; } - if (!identifierPreservation) { - return `Additional focus:\n${custom}`; - } - return `${identifierPreservation}\n\nAdditional focus:\n${custom}`; + return identifierPreservation + ? `${identifierPreservation}\n\nAdditional focus:\n${custom}` + : `Additional focus:\n${custom}`; } -async function summarizeChunks(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - headers?: Record; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - customInstructions?: string; - summarizationInstructions?: CompactionSummarizationInstructions; - previousSummary?: string; - thinkingLevel?: ThinkingLevel; - streamFn?: StreamFn; -}): Promise { +async function summarizeChunks(params: CompactionSummaryParams): Promise { if (params.messages.length === 0) { return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; } @@ -132,8 +128,7 @@ async function summarizeChunks(params: { params.customInstructions, params.summarizationInstructions, ); - let hasGeneratedChunk = false; - for (const chunk of chunks) { + for (const [completedChunks, chunk] of chunks.entries()) { try { summary = await retryAsync( () => @@ -158,43 +153,26 @@ async function summarizeChunks(params: { // Backoff must honor caller cancellation; otherwise an abort during // the sleep would stall compaction until the full delay elapses. sleep: (ms) => sleepWithAbort(ms, params.signal), - shouldRetry: (err) => { - // Stop retrying when the caller explicitly cancelled. - if (params.signal.aborted) { - return false; - } - // Preserve existing non-retry policy for real network/transport - // timeouts (e.g. "fetch failed", ETIMEDOUT) that are not AbortErrors. - if (!isAbortError(err) && isTimeoutError(err)) { - return false; - } - // Provider-side AbortErrors with signal not yet aborted are - // transient disconnects — retrying is correct. - return true; - }, + // Caller aborts and transport timeouts are terminal; provider-side + // AbortErrors without caller cancellation remain retryable. + shouldRetry: (err) => + !params.signal.aborted && (isAbortError(err) || !isTimeoutError(err)), }, ); - hasGeneratedChunk = true; } catch (err) { - // Propagate only when the caller explicitly cancelled. Provider-side - // AbortErrors (signal not aborted) fall through to partial/fallback paths. - if (params.signal.aborted) { - throw err; - } - // Real non-abort transport timeouts still propagate immediately. - if (!isAbortError(err) && isTimeoutError(err)) { - throw err; - } - // No chunk has succeeded yet — rethrow so summarizeWithFallback - // can run its existing "Context contained N messages" fallback. - if (!hasGeneratedChunk) { + // Caller aborts, transport timeouts, and failures before any completed + // chunk cannot produce a recoverable partial summary. + if ( + params.signal.aborted || + (!isAbortError(err) && isTimeoutError(err)) || + completedChunks === 0 + ) { throw err; } // At least one chunk succeeded — throw with the partial summary // attached so summarizeWithFallback can try the oversized-message // retry first and only fall back to the partial summary if that // also fails. - const completedChunks = chunks.indexOf(chunk); log.warn("chunk summarization failed after retries; partial summary available", { err, completedChunks, @@ -210,65 +188,22 @@ async function summarizeChunks(params: { return summary ?? DEFAULT_SUMMARY_FALLBACK; } -function generateSummary( - currentMessages: AgentMessage[], - model: NonNullable, - reserveTokens: number, - apiKey: string, - headers: Record | undefined, - signal: AbortSignal, - customInstructions?: string, - previousSummary?: string, - thinkingLevel?: ThinkingLevel, - streamFn?: StreamFn, -): Promise { - return agentGenerateSummary( - currentMessages, - model, - reserveTokens, - apiKey, - headers, - signal, - customInstructions, - previousSummary, - thinkingLevel, - streamFn, - ); -} - /** * Summarize with progressive fallback for handling oversized messages. * If full summarization fails, tries partial summarization excluding oversized messages. */ -async function summarizeWithFallbackResult(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - headers?: Record; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - contextWindow: number; - customInstructions?: string; - summarizationInstructions?: CompactionSummarizationInstructions; - previousSummary?: string; - thinkingLevel?: ThinkingLevel; - streamFn?: StreamFn; -}): Promise { +async function summarizeWithFallback(params: CompactionSummaryParams): Promise { const { messages, contextWindow } = params; if (messages.length === 0) { - return { - kind: "summary", - text: params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK, - }; + return params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK; } // Try full summarization first let partialSummaryFallback: string | undefined; let lastError: unknown; try { - return { kind: "summary", text: await summarizeChunks(params) }; + return await summarizeChunks(params); } catch (err) { lastError = err; if (params.signal.aborted) { @@ -284,6 +219,7 @@ async function summarizeWithFallbackResult(params: { contextWindow, signal: params.signal, }); + const oversizedSuffix = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; // When nothing was oversized, `smallMessages` is the same transcript as the full attempt. // Re-summarizing it would duplicate the same failing API work (and duplicate warn logs). @@ -293,8 +229,7 @@ async function summarizeWithFallbackResult(params: { ...params, messages: smallMessages, }); - const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; - return { kind: "summary", text: partialSummary + notes }; + return partialSummary + oversizedSuffix; } catch (partialError) { lastError = partialError; if (params.signal.aborted) { @@ -306,15 +241,14 @@ async function summarizeWithFallbackResult(params: { // so the model knows large content was filtered. const retryPartial = (lastError as PartialSummaryError).partialSummary; if (retryPartial) { - const notes = oversizedNotes.length > 0 ? `\n\n${oversizedNotes.join("\n")}` : ""; - partialSummaryFallback = retryPartial + notes; + partialSummaryFallback = retryPartial + oversizedSuffix; } } } // Final fallback: use best available partial summary, otherwise throw error if (partialSummaryFallback) { - return { kind: "summary", text: partialSummaryFallback }; + return partialSummaryFallback; } // All summarization attempts failed — throw error so caller knows compaction @@ -328,16 +262,10 @@ async function summarizeWithFallbackResult(params: { ); } -async function summarizeWithFallback( - params: Parameters[0], -): Promise { - return (await summarizeWithFallbackResult(params)).text; -} - /** Extracts a compact timestamp range from a chunk of messages for merge metadata. */ function extractChunkTimeRange(chunk: AgentMessage[]): string { - let earliest: number | undefined; - let latest: number | undefined; + let earliest = Number.POSITIVE_INFINITY; + let latest = 0; for (const message of chunk) { const timestamp = message.timestamp; if ( @@ -347,10 +275,10 @@ function extractChunkTimeRange(chunk: AgentMessage[]): string { ) { continue; } - earliest = earliest === undefined ? timestamp : Math.min(earliest, timestamp); - latest = latest === undefined ? timestamp : Math.max(latest, timestamp); + earliest = Math.min(earliest, timestamp); + latest = Math.max(latest, timestamp); } - if (earliest === undefined || latest === undefined) { + if (!Number.isFinite(earliest)) { return ""; } const format = (timestamp: number) => @@ -360,29 +288,15 @@ function extractChunkTimeRange(chunk: AgentMessage[]): string { } /** Summarizes history in multiple stages when a single pass would be too large. */ -export async function summarizeInStages(params: { - messages: AgentMessage[]; - model: NonNullable; - apiKey: string; - headers?: Record; - signal: AbortSignal; - reserveTokens: number; - maxChunkTokens: number; - contextWindow: number; - customInstructions?: string; - summarizationInstructions?: CompactionSummarizationInstructions; - previousSummary?: string; - parts?: number; - minMessagesForSplit?: number; - thinkingLevel?: ThinkingLevel; - streamFn?: StreamFn; -}): Promise { +export async function summarizeInStages( + params: CompactionSummaryParams & { + parts?: number; + minMessagesForSplit?: number; + }, +): Promise { const { messages } = params; if (messages.length === 0) { - return { - kind: "summary", - text: params.previousSummary ?? DEFAULT_SUMMARY_FALLBACK, - }; + return { kind: "summary", text: await summarizeWithFallback(params) }; } const plan = await buildStageSplitPlanWithWorker({ @@ -394,18 +308,18 @@ export async function summarizeInStages(params: { }); if (plan.mode === "single") { - return summarizeWithFallbackResult(params); + return { kind: "summary", text: await summarizeWithFallback(params) }; } const partialSummaries: string[] = []; for (const [index, chunk] of plan.chunks.entries()) { try { - const result = await summarizeWithFallbackResult({ + const summary = await summarizeWithFallback({ ...params, messages: chunk, previousSummary: undefined, }); - partialSummaries.push(result.text); + partialSummaries.push(summary); } catch (err) { // A chunk summarization failed — fail the whole stages compaction. // This prevents silent infinite retry loops where compaction reports @@ -461,12 +375,14 @@ export async function summarizeInStages(params: { ? `${MERGE_SUMMARIES_INSTRUCTIONS}\n\n${custom}` : MERGE_SUMMARIES_INSTRUCTIONS; - const mergedResult = await summarizeWithFallbackResult({ - ...params, - messages: summaryMessages, - customInstructions: mergeInstructions, - }); - return mergedResult; + return { + kind: "summary", + text: await summarizeWithFallback({ + ...params, + messages: summaryMessages, + customInstructions: mergeInstructions, + }), + }; } /** Resolves a positive context-window token count from model metadata. */ diff --git a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts index ce7250efa0b7..c02b4b979dea 100644 --- a/src/agents/embedded-agent-runner/run/preemptive-compaction.ts +++ b/src/agents/embedded-agent-runner/run/preemptive-compaction.ts @@ -50,18 +50,28 @@ export type LlmBoundaryTokenPressure = { renderedChars?: number; }; -function estimateStringTokenPressure(text: string, charsPerToken = ESTIMATED_CHARS_PER_TOKEN) { - return Math.ceil(estimateStringChars(text) / charsPerToken); +type TokenPressureMode = "general" | "tool-result"; + +function estimateStringTokenPressure( + text: string, + charsPerToken = ESTIMATED_CHARS_PER_TOKEN, + mode: TokenPressureMode = "general", +) { + const estimatedTokens = Math.ceil(estimateStringChars(text) / charsPerToken); + return mode === "tool-result" + ? Math.max(Math.ceil(text.length / TOOL_RESULT_CHARS_PER_TOKEN), estimatedTokens) + : estimatedTokens; } function estimateJsonPayloadTokenPressure( value: unknown, charsPerToken = JSON_PAYLOAD_CHARS_PER_TOKEN, + mode: TokenPressureMode = "general", ): number { try { const serialized = JSON.stringify(value); return typeof serialized === "string" - ? Math.ceil(estimateStringChars(serialized) / charsPerToken) + ? estimateStringTokenPressure(serialized, charsPerToken, mode) : 1; } catch { return 256; @@ -89,75 +99,26 @@ function estimateIdentifierTokenPressure( function estimateContentBlockTokenPressure( block: unknown, charsPerToken = ESTIMATED_CHARS_PER_TOKEN, + mode: TokenPressureMode = "general", ): number { if (typeof block === "string") { - return estimateStringTokenPressure(block, charsPerToken); + return estimateStringTokenPressure(block, charsPerToken, mode); } if (!isRecord(block)) { - return estimateJsonPayloadTokenPressure(block, charsPerToken); + return estimateJsonPayloadTokenPressure(block, charsPerToken, mode); } const type = block.type; - if (type === "text" && typeof block.text === "string") { - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateStringTokenPressure(block.text, charsPerToken); - } - if (type === "thinking" && typeof block.thinking === "string") { - return ( - CONTENT_BLOCK_OVERHEAD_TOKENS + estimateStringTokenPressure(block.thinking, charsPerToken) - ); + const text = type === "text" ? block.text : type === "thinking" ? block.thinking : undefined; + if (typeof text === "string") { + return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateStringTokenPressure(text, charsPerToken, mode); } if (type === "image") { return IMAGE_BLOCK_TOKENS; } - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateJsonPayloadTokenPressure(block, charsPerToken); -} - -function estimateToolResultStringTokenPressure(text: string): number { - const conservativeToolResultEstimate = Math.ceil(text.length / TOOL_RESULT_CHARS_PER_TOKEN); - const cjkAwareEstimate = estimateStringTokenPressure(text); - return Math.max(conservativeToolResultEstimate, cjkAwareEstimate); -} - -function estimateToolResultJsonTokenPressure(value: unknown): number { - try { - const serialized = JSON.stringify(value); - return typeof serialized === "string" ? estimateToolResultStringTokenPressure(serialized) : 1; - } catch { - return 256; - } -} - -function estimateToolResultBlockTokenPressure(block: unknown): number { - if (typeof block === "string") { - return estimateToolResultStringTokenPressure(block); - } - if (!isRecord(block)) { - return estimateToolResultJsonTokenPressure(block); - } - - if (block.type === "text" && typeof block.text === "string") { - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultStringTokenPressure(block.text); - } - if (block.type === "thinking" && typeof block.thinking === "string") { - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultStringTokenPressure(block.thinking); - } - if (block.type === "image") { - return IMAGE_BLOCK_TOKENS; - } - return CONTENT_BLOCK_OVERHEAD_TOKENS + estimateToolResultJsonTokenPressure(block); -} - -function estimateToolResultContentTokenPressure(content: unknown): number { - if (typeof content === "string") { - return estimateToolResultStringTokenPressure(content); - } - if (Array.isArray(content)) { - return content.reduce((sum, block) => sum + estimateToolResultBlockTokenPressure(block), 0); - } - if (content !== undefined) { - return estimateToolResultJsonTokenPressure(content); - } - return 0; + return ( + CONTENT_BLOCK_OVERHEAD_TOKENS + estimateJsonPayloadTokenPressure(block, charsPerToken, mode) + ); } function estimateAssistantToolCallTokenPressure(block: Record): number { @@ -169,30 +130,36 @@ function estimateAssistantToolCallTokenPressure(block: Record): ); } -function estimateContentTokenPressure(content: unknown): number { +function estimateContentTokenPressure( + content: unknown, + mode: TokenPressureMode = "general", +): number { if (typeof content === "string") { - return estimateStringTokenPressure(content); + return estimateStringTokenPressure(content, ESTIMATED_CHARS_PER_TOKEN, mode); } if (Array.isArray(content)) { - return content.reduce((sum, block) => sum + estimateContentBlockTokenPressure(block), 0); + return content.reduce( + (sum, block) => + sum + estimateContentBlockTokenPressure(block, ESTIMATED_CHARS_PER_TOKEN, mode), + 0, + ); } if (content !== undefined) { - return estimateJsonPayloadTokenPressure(content); + return estimateJsonPayloadTokenPressure( + content, + mode === "tool-result" ? ESTIMATED_CHARS_PER_TOKEN : JSON_PAYLOAD_CHARS_PER_TOKEN, + mode, + ); } return 0; } -function isToolResultMessage(message: AgentMessage): boolean { - const record = message as unknown as { role?: unknown; type?: unknown }; - return record.role === "toolResult" || record.role === "tool" || record.type === "toolResult"; -} - function estimateMessageTokenPressure(message: AgentMessage): number { const record = message as unknown as Record; let tokens = MESSAGE_BOUNDARY_OVERHEAD_TOKENS; - if (isToolResultMessage(message)) { - tokens += estimateToolResultContentTokenPressure(record.content); + if (record.role === "toolResult" || record.role === "tool" || record.type === "toolResult") { + tokens += estimateContentTokenPressure(record.content, "tool-result"); tokens += estimateIdentifierTokenPressure(record.toolName ?? record.tool_name); return tokens; } @@ -207,18 +174,13 @@ function estimateMessageTokenPressure(message: AgentMessage): number { return tokens; } - if (record.role === "branchSummary") { + if (record.role === "branchSummary" || record.role === "compactionSummary") { const summary = typeof record.summary === "string" ? record.summary : ""; - tokens += estimateStringTokenPressure(BRANCH_SUMMARY_PREFIX + summary + BRANCH_SUMMARY_SUFFIX); - return tokens; - } - - if (record.role === "compactionSummary") { - const summary = typeof record.summary === "string" ? record.summary : ""; - tokens += estimateStringTokenPressure( - COMPACTION_SUMMARY_PREFIX + summary + COMPACTION_SUMMARY_SUFFIX, - ); - return tokens; + const [prefix, suffix] = + record.role === "branchSummary" + ? [BRANCH_SUMMARY_PREFIX, BRANCH_SUMMARY_SUFFIX] + : [COMPACTION_SUMMARY_PREFIX, COMPACTION_SUMMARY_SUFFIX]; + return tokens + estimateStringTokenPressure(prefix + summary + suffix); } if (record.role === "assistant") { @@ -255,6 +217,16 @@ function estimateMessageTokenPressure(message: AgentMessage): number { * optional system prompt, and current prompt text. The result intentionally * includes a safety margin because this path runs before provider tokenization. */ +function estimateRenderedPromptTokens(params: { systemPrompt?: string; prompt: string }): number { + const systemTokens = + typeof params.systemPrompt === "string" && params.systemPrompt.trim().length > 0 + ? MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.systemPrompt) + : 0; + return ( + systemTokens + MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.prompt) + ); +} + export function estimateLlmBoundaryTokenPressure(params: { messages: AgentMessage[]; systemPrompt?: string; @@ -264,13 +236,10 @@ export function estimateLlmBoundaryTokenPressure(params: { (sum, message) => sum + estimateMessageTokenPressure(message), 0, ); - const systemTokens = - typeof params.systemPrompt === "string" && params.systemPrompt.trim().length > 0 - ? MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.systemPrompt) - : 0; - const promptTokens = - MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.prompt); - return Math.max(0, Math.ceil((historyTokens + systemTokens + promptTokens) * SAFETY_MARGIN)); + return Math.max( + 0, + Math.ceil((historyTokens + estimateRenderedPromptTokens(params)) * SAFETY_MARGIN), + ); } /** Estimates only the rendered prompt/system portion when history has already been accounted for. */ @@ -278,13 +247,7 @@ export function estimateRenderedLlmBoundaryTokenPressure(params: { systemPrompt?: string; prompt: string; }): number { - const systemTokens = - typeof params.systemPrompt === "string" && params.systemPrompt.trim().length > 0 - ? MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.systemPrompt) - : 0; - const promptTokens = - MESSAGE_BOUNDARY_OVERHEAD_TOKENS + estimateStringTokenPressure(params.prompt); - return Math.max(0, Math.ceil((systemTokens + promptTokens) * SAFETY_MARGIN)); + return Math.max(0, Math.ceil(estimateRenderedPromptTokens(params) * SAFETY_MARGIN)); } function normalizeLlmBoundaryTokenPressure( diff --git a/src/agents/embedded-agent-runner/tool-result-truncation.ts b/src/agents/embedded-agent-runner/tool-result-truncation.ts index 004d040169b8..18bd25cc5f97 100644 --- a/src/agents/embedded-agent-runner/tool-result-truncation.ts +++ b/src/agents/embedded-agent-runner/tool-result-truncation.ts @@ -64,7 +64,7 @@ export function resolveCacheTtlPruningSettings( } catch { // Invalid durations retain the shipped five-minute default. } - const normalize = (value: string) => normalizeLowercaseStringOrEmpty(value); + const normalize = normalizeLowercaseStringOrEmpty; const deny = compileGlobPatterns({ raw: config.tools?.deny, normalize }); const allow = compileGlobPatterns({ raw: config.tools?.allow, normalize }); return { @@ -107,22 +107,26 @@ function cacheTtlMessageChars(message: AgentMessage): number { } const content = Array.isArray(message.content) ? message.content : []; return content.reduce((chars, block) => { + if (!isRecord(block)) { + return chars; + } const text = cacheTtlText(block, message.role !== "assistant"); if (text !== undefined) { return chars + estimateStringChars(text); } - if (isRecord(block) && block.type === "image") { + if (block.type === "image") { return chars + CACHE_TTL_IMAGE_CHARS; } - if (!isRecord(block) || message.role !== "assistant") { + if (message.role !== "assistant") { return chars; } const record = block as Record; if (record.type === "thinking" || record.type === "redacted_thinking") { - const values = [record.thinking, record.thinkingSignature]; - if (record.type === "redacted_thinking") { - values.push(record.data); - } + const values = [ + record.thinking, + record.thinkingSignature, + ...(record.type === "redacted_thinking" ? [record.data] : []), + ]; return values.reduce( (sum, value) => sum + (typeof value === "string" ? estimateStringChars(value) : 0), chars, @@ -209,11 +213,12 @@ export function pruneExpiredCacheTtlToolResults(params: { (next ??= messages.slice())[index] = projected; } } - if (totalChars / charWindow < 0.5 || !settings.hardClear) { - return next ?? messages; - } const output = next ?? messages; - if (eligible.reduce((sum, index) => sum + cacheTtlMessageChars(output[index]!), 0) < 50_000) { + if ( + totalChars / charWindow < 0.5 || + !settings.hardClear || + eligible.reduce((sum, index) => sum + cacheTtlMessageChars(output[index]!), 0) < 50_000 + ) { return output; } for (const index of eligible) { @@ -510,18 +515,10 @@ export function truncateToolResultMessage( const preserveSmallBlocks = smallBlockChars + largeBlockNoticeChars <= maxChars; const preservedChars = preserveSmallBlocks ? smallBlockChars : 0; const remainingBudget = Math.max(0, maxChars - preservedChars); - const reducibleChars = blockTextChars.reduce( - (sum, chars) => sum + (preserveSmallBlocks && chars > 0 && chars <= minKeepChars ? 0 : chars), - 0, - ); - const reducibleNoticeChars = blockTextChars.reduce( - (sum, chars, index) => - sum + - (preserveSmallBlocks && chars > 0 && chars <= minKeepChars - ? 0 - : (blockNoticeChars[index] ?? 0)), - 0, - ); + const reducibleChars = totalTextChars - preservedChars; + const reducibleNoticeChars = preserveSmallBlocks + ? largeBlockNoticeChars + : blockNoticeChars.reduce((sum, chars) => sum + chars, 0); const noticeScale = reducibleNoticeChars > 0 ? Math.min(1, remainingBudget / reducibleNoticeChars) : 0; const distributableBudget = Math.max(0, remainingBudget - reducibleNoticeChars); @@ -566,13 +563,7 @@ function isToolResultTextBlock( ); } -type ToolResultSpillDetails = { - path: string; - truncated: boolean; - chars?: number; -}; - -function getToolResultSpillDetails(message: AgentMessage): ToolResultSpillDetails | undefined { +function getToolResultSpillDetails(message: AgentMessage) { const details = (message as { details?: unknown }).details; if (!isRecord(details)) { return undefined; @@ -638,21 +629,6 @@ function resolveAggregateElisionMarkers( }; } -function formatAggregateElisionText( - remainingTextBudget: number, - spillMarkers: AggregateElisionMarkers | undefined, -): string { - if (remainingTextBudget <= 0) { - return ""; - } - for (const marker of [spillMarkers?.full, spillMarkers?.compact]) { - if (marker && estimateToolResultTextChars(marker) <= remainingTextBudget) { - return marker; - } - } - return sliceToolResultTextToBudget(AGGREGATE_ELISION_MARKER, remainingTextBudget); -} - /** Projects bounded tool-result history without mutating the transcript. */ export function truncateOversizedToolResultsInMessages( messages: AgentMessage[], @@ -667,50 +643,25 @@ export function truncateOversizedToolResultsInMessages( aggregatePressureEngaged: boolean; aggregateBudgetChars: number; } { - const maxChars = Math.max( - 1, - maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens), - ); - const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars( + const { maxChars, aggregateBudgetChars } = resolveToolResultBudgets({ contextWindowTokens, - maxChars, + maxCharsOverride, aggregateMaxCharsOverride, - ); - const projectionKeys = projectionState - ? getToolResultProjectionKeys(messages, projectionState) - : []; - const hasFrozenProjectionBaseline = (projectionState?.frozen.size ?? 0) > 0; - const branch = messages.map((message, index) => { - const projectionKey = projectionKeys[index]; - const projectedMessage = projectionKey - ? projectionState?.replacements.get(projectionKey) - : undefined; - if (projectionKey && projectionState && !projectionState.sourceTextByKey.has(projectionKey)) { - projectionState.sourceTextByKey.set(projectionKey, getToolResultTextBlocks(message)); - } - const mergedMessage = projectedMessage - ? mergeProjectedToolResultMessage( - message, - projectedMessage, - projectionState?.sourceTextByKey.get(projectionKey ?? ""), - ) - : message; - return { - id: `message-${index}`, - type: "message", - message: mergedMessage, - aggregateEligible: - !projectionKey || - !projectionState?.frozen.has(projectionKey) || - (projectedMessage !== undefined && mergedMessage === message), - // Reduce frozen history first so steering cannot make fresh output disappear. - deferAggregateRecovery: - projectionKey !== undefined && - projectionState !== undefined && - hasFrozenProjectionBaseline && - !projectionState.frozen.has(projectionKey), - }; }); + const sourceBranch = messages.map((message, index) => ({ + id: `message-${index}`, + type: "message", + message, + })); + const projection = projectionState + ? projectToolResultBranch({ + branch: sourceBranch, + projectionState, + recordSources: true, + }) + : undefined; + const branch = projection?.branch ?? sourceBranch; + const projectionKeys = projection?.keys ?? []; const plan = buildToolResultReplacementPlan({ branch, maxChars, @@ -718,7 +669,7 @@ export function truncateOversizedToolResultsInMessages( minKeepChars: RECOVERY_MIN_KEEP_CHARS, protectTrailingToolResults: Boolean(projectionState), }); - const replacedBranch = applyToolResultReplacementsToBranch(branch, plan.replacements); + const replacedBranch = plan.branch; if (projectionState) { for (const [index, originalMessage] of messages.entries()) { const projectedMessage = replacedBranch[index]?.message; @@ -745,19 +696,26 @@ export function truncateOversizedToolResultsInMessages( }; } -function calculateRecoveryAggregateToolResultChars( - contextWindowTokens: number, - maxCharsOverride?: number, - aggregateMaxCharsOverride?: number, -): number { - return Math.max( +function resolveToolResultBudgets(params: { + contextWindowTokens: number; + maxCharsOverride?: number; + aggregateMaxCharsOverride?: number; +}): { maxChars: number; aggregateBudgetChars: number } { + const maxChars = Math.max( 1, - aggregateMaxCharsOverride ?? - resolveLiveToolResultAggregateMaxChars({ - contextWindowTokens, - perResultMaxChars: maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens), - }), + params.maxCharsOverride ?? calculateMaxToolResultChars(params.contextWindowTokens), ); + return { + maxChars, + aggregateBudgetChars: Math.max( + 1, + params.aggregateMaxCharsOverride ?? + resolveLiveToolResultAggregateMaxChars({ + contextWindowTokens: params.contextWindowTokens, + perResultMaxChars: maxChars, + }), + ), + }; } type ToolResultReductionPotential = { @@ -849,78 +807,74 @@ function mergeProjectedToolResultMessage( if (!Array.isArray(currentContent) || !Array.isArray(projectedContent)) { return projectedMessage; } - const projectedText = projectedContent.filter( - (block): block is { type: "text"; text: string } => - Boolean(block) && - typeof block === "object" && - (block as { type?: unknown }).type === "text" && - typeof (block as { text?: unknown }).text === "string", + const projectedText = projectedContent.flatMap((block) => + isRecord(block) && block.type === "text" && typeof block.text === "string" ? [block.text] : [], ); const currentText = getToolResultTextBlocks(message); - if (sourceText && currentText.some((text, index) => text !== sourceText[index])) { - return message; - } - const currentTextCount = currentContent.filter( - (block) => - Boolean(block) && typeof block === "object" && (block as { type?: unknown }).type === "text", - ).length; - if (currentTextCount !== projectedText.length) { + if ( + (sourceText && currentText.some((text, index) => text !== sourceText[index])) || + currentText.length !== projectedText.length + ) { return message; } let textIndex = 0; const mergedContent = currentContent.map((block) => { - if (!block || typeof block !== "object" || (block as { type?: unknown }).type !== "text") { + if (!isRecord(block) || block.type !== "text") { return block; } - const projectedBlock = projectedText[textIndex++]; - return projectedBlock ? Object.assign({}, block, { text: projectedBlock.text }) : block; + return Object.assign({}, block, { text: projectedText[textIndex++] }); }); return { ...message, content: mergedContent } as AgentMessage; } -function seedRecoveryBranchFromFrozenProjection(params: { +function projectToolResultBranch(params: { branch: ToolResultBranchEntry[]; projectionState: ToolResultPromptProjectionState; -}): ToolResultBranchEntry[] { + frozenOnly?: boolean; + recordSources?: boolean; +}): { branch: ToolResultBranchEntry[]; keys: Array } { const messageEntries = params.branch.filter( (entry): entry is ToolResultBranchEntry & { message: AgentMessage } => entry.type === "message" && entry.message !== undefined, ); - const projectionKeys = getToolResultProjectionKeys( + const keys = getToolResultProjectionKeys( messageEntries.map((entry) => entry.message), params.projectionState, ); const hasFrozenProjectionBaseline = params.projectionState.frozen.size > 0; let messageIndex = 0; - return params.branch.map((entry) => { - if (entry.type !== "message" || !entry.message) { - return entry; - } - const projectionKey = projectionKeys[messageIndex++]; - const projectedMessage = - projectionKey && params.projectionState.frozen.has(projectionKey) - ? params.projectionState.replacements.get(projectionKey) - : undefined; - const message = projectedMessage - ? mergeProjectedToolResultMessage( - entry.message, - projectedMessage, - projectionKey ? params.projectionState.sourceTextByKey.get(projectionKey) : undefined, - ) - : entry.message; - return { - ...entry, - message, - aggregateEligible: - !projectionKey || - !params.projectionState.frozen.has(projectionKey) || - (projectedMessage !== undefined && message === entry.message), - deferAggregateRecovery: - projectionKey !== undefined && - hasFrozenProjectionBaseline && - !params.projectionState.frozen.has(projectionKey), - }; - }); + return { + keys, + branch: params.branch.map((entry) => { + if (entry.type !== "message" || !entry.message) { + return entry; + } + const key = keys[messageIndex++]; + const frozen = key !== undefined && params.projectionState.frozen.has(key); + const projected = + key && (!params.frozenOnly || frozen) + ? params.projectionState.replacements.get(key) + : undefined; + if (key && params.recordSources && !params.projectionState.sourceTextByKey.has(key)) { + params.projectionState.sourceTextByKey.set(key, getToolResultTextBlocks(entry.message)); + } + const message = projected + ? mergeProjectedToolResultMessage( + entry.message, + projected, + key ? params.projectionState.sourceTextByKey.get(key) : undefined, + ) + : entry.message; + return { + ...entry, + message, + aggregateEligible: + !key || !frozen || (projected !== undefined && message === entry.message), + // Reduce frozen history first so steering cannot make fresh output disappear. + deferAggregateRecovery: key !== undefined && hasFrozenProjectionBaseline && !frozen, + }; + }), + }; } function getToolResultTextBlocks(message: AgentMessage): string[] { @@ -939,26 +893,22 @@ function buildAggregateToolResultReplacements(params: { spillSourceBranch?: ToolResultBranchEntry[]; aggregateBudgetChars: number; minKeepChars?: number; - protectTrailingToolResults?: boolean; + protectedEntryIds?: Set; }): { replacements: ToolResultReplacement[]; pressureExceeded: boolean } { const minKeepChars = params.minKeepChars ?? MIN_KEEP_CHARS; - const protectedEntryIds = params.protectTrailingToolResults - ? getTrailingToolResultEntryIds(params.branch) - : new Set(); const candidates = params.branch .flatMap((entry, index) => { const message = entry.message; return entry.type === "message" && message?.role === "toolResult" ? [ { - index, entryId: entry.id, message, spillSourceMessage: params.spillSourceBranch?.[index]?.message ?? message, textLength: getToolResultTextBudget(message), aggregateEligible: entry.aggregateEligible !== false, deferredByFreshProjection: entry.deferAggregateRecovery === true, - protectedByTrailingBatch: protectedEntryIds.has(entry.id), + protectedByTrailingBatch: params.protectedEntryIds?.has(entry.id) ?? false, }, ] : []; @@ -983,68 +933,53 @@ function buildAggregateToolResultReplacements(params: { let remainingReduction = totalChars - params.aggregateBudgetChars; const replacements = new Map(); - const aggregateRecoveryCandidates = candidates - .filter((item) => !item.deferredByFreshProjection && !item.protectedByTrailingBatch) - .toSorted((a, b) => a.index - b.index); - const recoveryCandidates = [ - ...aggregateRecoveryCandidates.filter((item) => item.aggregateEligible), - // Reuse frozen projections first to keep reduction shrink-only and cache-stable. - ...aggregateRecoveryCandidates.filter((item) => !item.aggregateEligible), - ...candidates.filter( - (item) => item.deferredByFreshProjection && !item.protectedByTrailingBatch, - ), - ]; - - // Spend aggregate reduction on older entries first so fresh tool output stays intact. - for (const candidate of recoveryCandidates) { - if (remainingReduction <= 0) { - break; - } - const reducibleChars = Math.max(0, candidate.textLength - minTruncatedTextChars); - if (reducibleChars <= 0) { - continue; - } - - const requestedReduction = Math.min(reducibleChars, remainingReduction); - const targetChars = Math.max(minTruncatedTextChars, candidate.textLength - requestedReduction); - const spillMarkers = resolveAggregateElisionMarkers(candidate.spillSourceMessage); - const candidateSuffixFactory = spillMarkers?.truncationSuffix ?? suffixFactory; - const candidateTargetChars = Math.max( - targetChars, - estimateToolResultTextChars(candidateSuffixFactory(1)), + // Frozen projections shrink first; stable sorting preserves the original oldest-first order. + const recoveryCandidates = candidates + .filter((candidate) => !candidate.protectedByTrailingBatch) + .toSorted( + (left, right) => + Number(left.deferredByFreshProjection) - Number(right.deferredByFreshProjection) || + Number(right.aggregateEligible) - Number(left.aggregateEligible), ); - const truncatedMessage = truncateToolResultMessage(candidate.message, candidateTargetChars, { - minKeepChars, - suffix: candidateSuffixFactory, - }); - const newLength = getToolResultTextBudget(truncatedMessage); - const actualReduction = Math.max(0, candidate.textLength - newLength); - if (actualReduction <= 0) { - continue; - } - replacements.set(candidate.entryId, { entryId: candidate.entryId, message: truncatedMessage }); - remainingReduction -= actualReduction; - } - - for (const candidate of recoveryCandidates) { - if (remainingReduction <= 0) { - break; + // Trim all older entries before clearing any, so fresh output and spill pointers stay recoverable. + for (const clear of [false, true]) { + for (const candidate of recoveryCandidates) { + if (remainingReduction <= 0) { + break; + } + const baseMessage = replacements.get(candidate.entryId)?.message ?? candidate.message; + const baseTextLength = getToolResultTextBudget(baseMessage); + if (!clear && baseTextLength <= minTruncatedTextChars) { + continue; + } + const spillMarkers = resolveAggregateElisionMarkers(candidate.spillSourceMessage); + let message: AgentMessage; + if (clear) { + message = clearToolResultText( + candidate.message, + Math.max(0, baseTextLength - remainingReduction), + spillMarkers, + ); + } else { + const suffix = spillMarkers?.truncationSuffix ?? suffixFactory; + const targetChars = Math.max( + minTruncatedTextChars, + baseTextLength - remainingReduction, + estimateToolResultTextChars(suffix(1)), + ); + message = truncateToolResultMessage(candidate.message, targetChars, { + minKeepChars, + suffix, + }); + } + const actualReduction = Math.max(0, baseTextLength - getToolResultTextBudget(message)); + if (actualReduction <= 0 && (!clear || !spillMarkers)) { + continue; + } + replacements.set(candidate.entryId, { entryId: candidate.entryId, message }); + remainingReduction -= actualReduction; } - const baseMessage = replacements.get(candidate.entryId)?.message ?? candidate.message; - const baseTextLength = getToolResultTextBudget(baseMessage); - const spillMarkers = resolveAggregateElisionMarkers(candidate.spillSourceMessage); - const emptyMessage = clearToolResultText( - candidate.message, - Math.max(0, baseTextLength - remainingReduction), - spillMarkers, - ); - const actualReduction = Math.max(0, baseTextLength - getToolResultTextBudget(emptyMessage)); - if (actualReduction <= 0 && !spillMarkers) { - continue; - } - replacements.set(candidate.entryId, { entryId: candidate.entryId, message: emptyMessage }); - remainingReduction -= actualReduction; } return { replacements: [...replacements.values()], pressureExceeded: true }; @@ -1052,16 +987,14 @@ function buildAggregateToolResultReplacements(params: { function getTrailingToolResultEntryIds(branch: ToolResultBranchEntry[]): Set { const ids = new Set(); - let sawMessage = false; for (let index = branch.length - 1; index >= 0; index--) { const entry = branch[index]; if (entry?.type !== "message" || !entry.message) { - if (!sawMessage) { + if (ids.size === 0) { continue; } break; } - sawMessage = true; if ((entry.message as { role?: string }).role !== "toolResult") { break; } @@ -1094,7 +1027,12 @@ function clearToolResultText( if (!isToolResultTextBlock(block)) { return block; } - const replacementText = formatAggregateElisionText(remainingTextBudget, spillMarkers); + const replacementText = + [spillMarkers?.full, spillMarkers?.compact].find( + (marker): marker is string => + typeof marker === "string" && + estimateToolResultTextChars(marker) <= remainingTextBudget, + ) ?? sliceToolResultTextToBudget(AGGREGATE_ELISION_MARKER, remainingTextBudget); remainingTextBudget = Math.max( 0, remainingTextBudget - estimateToolResultTextChars(replacementText), @@ -1107,82 +1045,27 @@ function clearToolResultText( } as AgentMessage; } -function buildOversizedToolResultReplacements(params: { - branch: ToolResultBranchEntry[]; - maxChars: number; - minKeepChars?: number; - protectedEntryIds?: Set; -}): ToolResultReplacement[] { - const minKeepChars = params.minKeepChars ?? MIN_KEEP_CHARS; - const replacements: ToolResultReplacement[] = []; - - for (const entry of params.branch) { - if (entry.type !== "message" || !entry.message) { - continue; - } - const msg = entry.message; - if ((msg as { role?: string }).role !== "toolResult") { - continue; - } - if (getToolResultTextBudget(msg) <= params.maxChars) { - continue; - } - const replacementMinKeepChars = params.protectedEntryIds?.has(entry.id) - ? Math.max(minKeepChars, MIN_KEEP_CHARS) - : minKeepChars; - const spillMarkers = resolveAggregateElisionMarkers(msg); - const suffixFactory = spillMarkers?.truncationSuffix; - const maxChars = Math.max( - params.maxChars, - suffixFactory ? estimateToolResultTextChars(suffixFactory(1)) : 0, - ); - replacements.push({ - entryId: entry.id, - message: truncateToolResultMessage(msg, maxChars, { - minKeepChars: replacementMinKeepChars, - ...(suffixFactory ? { suffix: suffixFactory } : {}), - }), - }); - } - - return replacements; -} - -function calculateReplacementReduction( - branch: ToolResultBranchEntry[], - replacements: ToolResultReplacement[], -): number { - if (replacements.length === 0) { - return 0; - } - const branchById = new Map(branch.map((entry) => [entry.id, entry])); - return replacements.reduce((reduction, replacement) => { - const entry = branchById.get(replacement.entryId); - if (!entry?.message) { - return reduction; - } - return ( - reduction + - Math.max( - 0, - getToolResultTextBudget(entry.message) - getToolResultTextBudget(replacement.message), - ) - ); - }, 0); -} - function applyToolResultReplacementsToBranch( branch: ToolResultBranchEntry[], replacements: ToolResultReplacement[], -): ToolResultBranchEntry[] { +): { branch: ToolResultBranchEntry[]; reducedChars: number } { if (replacements.length === 0) { - return branch; + return { branch, reducedChars: 0 }; } const replacementsById = new Map(replacements.map(({ entryId, message }) => [entryId, message])); - return branch.map((entry) => { + let reducedChars = 0; + const nextBranch = branch.map((entry) => { const message = replacementsById.get(entry.id); - return message && entry.type === "message" ? { ...entry, message } : entry; + if (!message || entry.type !== "message" || !entry.message) { + return entry; + } + reducedChars += Math.max( + 0, + getToolResultTextBudget(entry.message) - getToolResultTextBudget(message), + ); + return { ...entry, message }; }); + return { branch: nextBranch, reducedChars }; } function buildToolResultReplacementPlan(params: { @@ -1192,6 +1075,7 @@ function buildToolResultReplacementPlan(params: { minKeepChars?: number; protectTrailingToolResults?: boolean; }): { + branch: ToolResultBranchEntry[]; replacements: ToolResultReplacement[]; oversizedReplacementCount: number; aggregateReplacementCount: number; @@ -1203,40 +1087,50 @@ function buildToolResultReplacementPlan(params: { const protectedEntryIds = params.protectTrailingToolResults ? getTrailingToolResultEntryIds(params.branch) : undefined; - const oversizedReplacements = buildOversizedToolResultReplacements({ - branch: params.branch, - maxChars: params.maxChars, - minKeepChars, - protectedEntryIds, + const oversizedReplacements = params.branch.flatMap((entry): ToolResultReplacement[] => { + const message = entry.message; + if ( + entry.type !== "message" || + message?.role !== "toolResult" || + getToolResultTextBudget(message) <= params.maxChars + ) { + return []; + } + const suffix = resolveAggregateElisionMarkers(message)?.truncationSuffix; + const maxChars = Math.max(params.maxChars, suffix ? estimateToolResultTextChars(suffix(1)) : 0); + return [ + { + entryId: entry.id, + message: truncateToolResultMessage(message, maxChars, { + minKeepChars: protectedEntryIds?.has(entry.id) + ? Math.max(minKeepChars, MIN_KEEP_CHARS) + : minKeepChars, + ...(suffix ? { suffix } : {}), + }), + }, + ]; }); - const oversizedReducibleChars = calculateReplacementReduction( - params.branch, - oversizedReplacements, - ); - const oversizedTrimmedBranch = applyToolResultReplacementsToBranch( - params.branch, - oversizedReplacements, - ); + const oversizedPhase = applyToolResultReplacementsToBranch(params.branch, oversizedReplacements); const aggregatePlan = buildAggregateToolResultReplacements({ - branch: oversizedTrimmedBranch, + branch: oversizedPhase.branch, spillSourceBranch: params.branch, aggregateBudgetChars: params.aggregateBudgetChars, minKeepChars, - protectTrailingToolResults: params.protectTrailingToolResults, + protectedEntryIds, }); - const aggregateReplacements = aggregatePlan.replacements; - const aggregateReducibleChars = calculateReplacementReduction( - oversizedTrimmedBranch, - aggregateReplacements, + const aggregatePhase = applyToolResultReplacementsToBranch( + oversizedPhase.branch, + aggregatePlan.replacements, ); return { - replacements: [...oversizedReplacements, ...aggregateReplacements], + branch: aggregatePhase.branch, + replacements: [...oversizedReplacements, ...aggregatePlan.replacements], oversizedReplacementCount: oversizedReplacements.length, - aggregateReplacementCount: aggregateReplacements.length, + aggregateReplacementCount: aggregatePlan.replacements.length, aggregatePressureExceeded: aggregatePlan.pressureExceeded, - oversizedReducibleChars, - aggregateReducibleChars, + oversizedReducibleChars: oversizedPhase.reducedChars, + aggregateReducibleChars: aggregatePhase.reducedChars, }; } @@ -1252,20 +1146,13 @@ function buildRecoveryToolResultReplacementPlan(params: { aggregateBudgetChars: number; plan: ReturnType; } { - const maxChars = Math.max( - 1, - params.maxCharsOverride ?? calculateMaxToolResultChars(params.contextWindowTokens), - ); - const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars( - params.contextWindowTokens, - maxChars, - params.aggregateMaxCharsOverride, - ); + const { maxChars, aggregateBudgetChars } = resolveToolResultBudgets(params); const projectedBranch = params.projectionState - ? seedRecoveryBranchFromFrozenProjection({ + ? projectToolResultBranch({ branch: params.branch, projectionState: params.projectionState, - }) + frozenOnly: true, + }).branch : params.branch; const plan = buildToolResultReplacementPlan({ branch: projectedBranch, @@ -1274,9 +1161,8 @@ function buildRecoveryToolResultReplacementPlan(params: { minKeepChars: RECOVERY_MIN_KEEP_CHARS, protectTrailingToolResults: params.protectTrailingToolResults, }); - const finalBranch = applyToolResultReplacementsToBranch(projectedBranch, plan.replacements); const replacements = params.branch.flatMap((entry, index) => { - const finalEntry = finalBranch[index]; + const finalEntry = plan.branch[index]; if ( entry.type !== "message" || !entry.message || @@ -1304,16 +1190,8 @@ export function estimateToolResultReductionPotential(params: { maxCharsOverride?: number; aggregateMaxCharsOverride?: number; }): ToolResultReductionPotential { - const { messages, contextWindowTokens } = params; - const maxChars = Math.max( - 1, - params.maxCharsOverride ?? calculateMaxToolResultChars(contextWindowTokens), - ); - const aggregateBudgetChars = calculateRecoveryAggregateToolResultChars( - contextWindowTokens, - maxChars, - params.aggregateMaxCharsOverride, - ); + const { messages } = params; + const { maxChars, aggregateBudgetChars } = resolveToolResultBudgets(params); const branch = messages.map((message, index) => ({ id: `message-${index}`, type: "message", diff --git a/src/agents/embedded-agent-subscribe.handlers.compaction.ts b/src/agents/embedded-agent-subscribe.handlers.compaction.ts index d082a36f729c..a08a470bd197 100644 --- a/src/agents/embedded-agent-subscribe.handlers.compaction.ts +++ b/src/agents/embedded-agent-subscribe.handlers.compaction.ts @@ -44,6 +44,48 @@ function compactionLogKind(reason: CompactionReason): string { return reason === "manual" ? "manual compaction" : "auto-compaction"; } +function emitCompactionAgentEvent( + ctx: EmbeddedAgentSubscribeContext, + data: { phase: "start" } | { phase: "end"; willRetry: boolean; completed: boolean }, +): void { + const event = { stream: "compaction" as const, data }; + emitAgentEvent({ runId: ctx.params.runId, ...event }); + runBestEffortCallback({ + label: "compaction agent event", + log: ctx.log, + callback: () => ctx.params.onAgentEvent?.(event), + }); +} + +function runBestEffortCompactionHook( + ctx: EmbeddedAgentSubscribeContext, + phase: "before" | "after", +): void { + const hookRunner = getGlobalHookRunner(); + const hookName = phase === "before" ? "before_compaction" : "after_compaction"; + if (!hookRunner?.hasHooks(hookName)) { + return; + } + const metrics = { + messageCount: ctx.params.session.messages?.length ?? 0, + sessionFile: ctx.params.session.sessionFile, + }; + const context = { sessionKey: ctx.params.sessionKey }; + const hook = + phase === "before" + ? hookRunner.runBeforeCompaction( + { ...metrics, messages: ctx.params.session.messages }, + context, + ) + : hookRunner.runAfterCompaction( + { ...metrics, compactedCount: ctx.getCompactionCount() }, + context, + ); + void hook.catch((err: unknown) => { + ctx.log.warn(`${hookName} hook failed: ${String(err)}`); + }); +} + /** Handles compaction start events from an embedded agent session. */ export function handleCompactionStart( ctx: EmbeddedAgentSubscribeContext, @@ -60,40 +102,11 @@ export function handleCompactionStart( reason, consoleMessage: `embedded run ${kind} start: runId=${ctx.params.runId} reason=${reason}`, }); - emitAgentEvent({ - runId: ctx.params.runId, - stream: "compaction", - data: { phase: "start" }, - }); - runBestEffortCallback({ - label: "compaction agent event", - log: ctx.log, - callback: () => - ctx.params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "start" }, - }), - }); + emitCompactionAgentEvent(ctx, { phase: "start" }); // Hooks are fire-and-forget so compaction state updates and liveness pauses // cannot be delayed by plugin work. - const hookRunner = getGlobalHookRunner(); - if (hookRunner?.hasHooks("before_compaction")) { - void hookRunner - .runBeforeCompaction( - { - messageCount: ctx.params.session.messages?.length ?? 0, - messages: ctx.params.session.messages, - sessionFile: ctx.params.session.sessionFile, - }, - { - sessionKey: ctx.params.sessionKey, - }, - ) - .catch((err: unknown) => { - ctx.log.warn(`before_compaction hook failed: ${String(err)}`); - }); - } + runBestEffortCompactionHook(ctx, "before"); } /** Handles compaction completion, retry, and incomplete events. */ @@ -107,7 +120,8 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com // trimming context, and the persisted count must reflect that successful trim. const hasResult = evt.result != null; const wasAborted = Boolean(evt.aborted); - if (hasResult && !wasAborted) { + const completed = hasResult && !wasAborted; + if (completed) { ctx.incrementCompactionCount(); const tokensAfter = typeof evt.result === "object" && evt.result @@ -130,14 +144,18 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com compactionCount: observedCompactionCount, consoleMessage: `embedded run ${kind} complete: runId=${ctx.params.runId} reason=${reason} compactionCount=${observedCompactionCount} willRetry=${willRetry}`, }); - void reconcileSessionStoreCompactionCountAfterSuccess({ - sessionKey: ctx.params.sessionKey, - agentId: ctx.params.agentId, - configStore: ctx.params.config?.session?.store, - observedCompactionCount, - }).catch((err: unknown) => { - ctx.log.warn(`late compaction count reconcile failed: ${String(err)}`); - }); + void import("./embedded-agent-subscribe.handlers.compaction.runtime.js") + .then(({ default: reconcile }) => + reconcile({ + sessionKey: ctx.params.sessionKey, + agentId: ctx.params.agentId, + configStore: ctx.params.config?.session?.store, + observedCompactionCount, + }), + ) + .catch((err: unknown) => { + ctx.log.warn(`late compaction count reconcile failed: ${String(err)}`); + }); } if (willRetry) { ctx.noteCompactionRetry(); @@ -148,9 +166,17 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com ctx.state.livenessState = "working"; } ctx.maybeResolveCompactionWait(); - clearStaleAssistantUsageOnSessionMessages(ctx); + const messages = ctx.params.session.messages; + if (Array.isArray(messages)) { + // Marker-free final compaction has no fresh boundary, so stale totals + // must be cleared before later context counters inspect assistant usage. + stripStaleAssistantUsageBeforeLatestCompaction(messages, { + mutate: true, + whenMissingCompactionSummary: "zeroAssistantUsage", + }); + } } - if (!hasResult || wasAborted) { + if (!completed) { ctx.log.info(`embedded run ${kind} incomplete`, { event: "embedded_run_compaction_end", runId: ctx.params.runId, @@ -161,65 +187,11 @@ export function handleCompactionEnd(ctx: EmbeddedAgentSubscribeContext, evt: Com consoleMessage: `embedded run ${kind} incomplete: runId=${ctx.params.runId} reason=${reason} aborted=${wasAborted} willRetry=${willRetry}`, }); } - emitAgentEvent({ - runId: ctx.params.runId, - stream: "compaction", - data: { phase: "end", willRetry, completed: hasResult && !wasAborted }, - }); - runBestEffortCallback({ - label: "compaction agent event", - log: ctx.log, - callback: () => - ctx.params.onAgentEvent?.({ - stream: "compaction", - data: { phase: "end", willRetry, completed: hasResult && !wasAborted }, - }), - }); + emitCompactionAgentEvent(ctx, { phase: "end", willRetry, completed }); // after_compaction runs only once the run will not retry, matching the visible // post-compaction session state plugin authors observe. if (!willRetry) { - const hookRunnerEnd = getGlobalHookRunner(); - if (hookRunnerEnd?.hasHooks("after_compaction")) { - void hookRunnerEnd - .runAfterCompaction( - { - messageCount: ctx.params.session.messages?.length ?? 0, - compactedCount: ctx.getCompactionCount(), - sessionFile: ctx.params.session.sessionFile, - }, - { sessionKey: ctx.params.sessionKey }, - ) - .catch((err: unknown) => { - ctx.log.warn(`after_compaction hook failed: ${String(err)}`); - }); - } + runBestEffortCompactionHook(ctx, "after"); } } - -/** Lazily reconciles persisted compaction count after a successful compaction. */ -async function reconcileSessionStoreCompactionCountAfterSuccess(params: { - sessionKey?: string; - agentId?: string; - configStore?: string; - observedCompactionCount: number; - now?: number; -}): Promise { - const { default: reconcile } = - await import("./embedded-agent-subscribe.handlers.compaction.runtime.js"); - return reconcile(params); -} - -function clearStaleAssistantUsageOnSessionMessages(ctx: EmbeddedAgentSubscribeContext): void { - const messages = ctx.params.session.messages; - if (!Array.isArray(messages)) { - return; - } - // Marker-free final compaction has no fresh boundary to compare against. - // Clear all assistant usage or stale pre-compaction totals keep driving the - // context counter after cleanup. - stripStaleAssistantUsageBeforeLatestCompaction(messages, { - mutate: true, - whenMissingCompactionSummary: "zeroAssistantUsage", - }); -} diff --git a/src/agents/main-session-recovery-lifecycle.ts b/src/agents/main-session-recovery-lifecycle.ts index cd3fd3ddf70a..cd52668708d5 100644 --- a/src/agents/main-session-recovery-lifecycle.ts +++ b/src/agents/main-session-recovery-lifecycle.ts @@ -1,12 +1,13 @@ import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; import { mergeRestartRecoveryTerminalRunIds } from "../config/sessions/restart-recovery-state.js"; +import { retryAsync } from "../infra/retry.js"; import { buildMainSessionRecoveryClearPatch, type MainRecoveryStateFields, } from "./main-session-recovery-clear.js"; -const MAIN_RESTART_RECOVERY_WEDGED_FALLBACK_REASON = - "main-session restart recovery is tombstoned for this session"; +const MAIN_SESSION_RECOVERY_RETRY_DELAY_MS = 1_000; +const MAIN_SESSION_RECOVERY_RETRY_MAX_DELAY_MS = 30_000; type MainRecoveryLifecycleEvent = { runId?: string; @@ -14,26 +15,45 @@ type MainRecoveryLifecycleEvent = { data?: { error?: unknown; phase?: unknown; stopReason?: unknown }; }; -export function inspectMainSessionRecoveryHealth(entry: SessionEntry): - | { status: "none" } - | { status: "active" } - | { - status: "tombstoned"; - reason: string; - repair: "clear_stale_abort" | null; - } { - const state = entry.mainRestartRecovery; - if (!state) { - return { status: "none" }; +export async function retryMainSessionRecoveryMutation(mutation: () => Promise): Promise { + return await retryAsync(mutation, 3, 25); +} + +/** Retries now, then leaves an exact idempotent repair queued after transient failure. */ +export async function repairMainSessionRecoveryMutation(params: { + mutation: () => Promise; + onDeferredSuccess: (result: T) => void | Promise; + onError: (error: unknown) => void; +}): Promise { + try { + return await retryMainSessionRecoveryMutation(params.mutation); + } catch (error) { + params.onError(error); + scheduleMainSessionRecoveryMutation({ + mutation: () => retryMainSessionRecoveryMutation(params.mutation), + onSuccess: params.onDeferredSuccess, + }); + return undefined; } - if (!state.tombstone) { - return { status: "active" }; - } - return { - status: "tombstoned", - reason: state.tombstone.reason.trim() || MAIN_RESTART_RECOVERY_WEDGED_FALLBACK_REASON, - repair: entry.abortedLastRun === true ? "clear_stale_abort" : null, - }; +} + +/** Keeps an idempotent durable-state repair alive until it succeeds or restart retires it. */ +export function scheduleMainSessionRecoveryMutation(params: { + mutation: () => Promise; + onError?: (error: unknown) => void; + onSuccess: (result: T) => void | Promise; + delayMs?: number; +}): void { + const delayMs = params.delayMs ?? MAIN_SESSION_RECOVERY_RETRY_DELAY_MS; + setTimeout(() => { + void params.mutation().then(params.onSuccess, (error: unknown) => { + params.onError?.(error); + scheduleMainSessionRecoveryMutation({ + ...params, + delayMs: Math.min(delayMs * 2, MAIN_SESSION_RECOVERY_RETRY_MAX_DELAY_MS), + }); + }); + }, delayMs).unref?.(); } function lifecyclePhase(event: MainRecoveryLifecycleEvent): "start" | "end" | "error" | null { @@ -61,6 +81,47 @@ export function isMainSessionRecoveryLifecycleEvent(params: { ); } +function settleForegroundOwner( + entry: MainRecoveryStateFields, + runId: string, + lifecycleGeneration: string, + currentLifecycleGeneration: string, +) { + const state = entry.mainRestartRecovery; + const claims = state?.foregroundClaims; + const claimId = + lifecycleGeneration === currentLifecycleGeneration && + claims?.lifecycleGeneration === lifecycleGeneration + ? claims.tokens.find((token) => claims.runIdsByClaimId?.[token] === runId) + : undefined; + if (!state || !claims || !claimId) { + return { + hasCurrentOwner: + Boolean( + claims?.lifecycleGeneration === currentLifecycleGeneration && claims.tokens.length, + ) || state?.reservation?.lifecycleGeneration === currentLifecycleGeneration, + }; + } + const tokens = claims.tokens.filter((token) => token !== claimId); + const runIdsByClaimId = Object.fromEntries( + Object.entries(claims.runIdsByClaimId ?? {}).filter(([token]) => token !== claimId), + ); + const foregroundClaims = tokens.length + ? { + lifecycleGeneration: claims.lifecycleGeneration, + tokens, + ...(Object.keys(runIdsByClaimId).length ? { runIdsByClaimId } : {}), + } + : undefined; + return { + claimId, + state: { ...state, revision: state.revision + 1, foregroundClaims }, + hasCurrentOwner: + Boolean(foregroundClaims) || + state.reservation?.lifecycleGeneration === currentLifecycleGeneration, + }; +} + export function projectMainSessionRecoveryLifecycle(params: { currentLifecycleGeneration: string; entry?: @@ -73,19 +134,17 @@ export function projectMainSessionRecoveryLifecycle(params: { event: MainRecoveryLifecycleEvent; snapshotPatch: Partial; }): { action: "suppress" } | { action: "apply"; patch: Partial } { + const apply = (patch: Partial) => ({ action: "apply" as const, patch }); if (params.entry?.mainRestartRecovery?.tombstone) { // Keep the operator boundary while allowing unrelated lifecycle status to settle. return isMainSessionRecoveryLifecycleEvent(params) ? { action: "suppress" } - : { - action: "apply", - patch: { - ...params.snapshotPatch, - abortedLastRun: params.entry.abortedLastRun, - restartRecoveryRuns: params.entry.restartRecoveryRuns, - mainRestartRecovery: params.entry.mainRestartRecovery, - }, - }; + : apply({ + ...params.snapshotPatch, + abortedLastRun: params.entry.abortedLastRun, + restartRecoveryRuns: params.entry.restartRecoveryRuns, + mainRestartRecovery: params.entry.mainRestartRecovery, + }); } if (isMainSessionRecoveryLifecycleEvent(params)) { return { action: "suppress" }; @@ -113,8 +172,13 @@ export function projectMainSessionRecoveryLifecycle(params: { ) : runs; if (settlesRecovery) { + if (!matchesFence || !runId || !lifecycleGeneration) { + // No terminal snapshot may settle a recovery row it cannot identify. + return params.entry?.mainRestartRecovery || runs?.length + ? { action: "suppress" } + : apply(patch); + } if ( - matchesFence && lifecycleGeneration !== params.currentLifecycleGeneration && remaining?.some( (run) => @@ -123,88 +187,34 @@ export function projectMainSessionRecoveryLifecycle(params: { ) { // Older generations share the live owner's run id. Consume only their // fence; recording that id as terminal would also tombstone its replacement. - return { action: "apply", patch: { restartRecoveryRuns: remaining } }; + return apply({ restartRecoveryRuns: remaining }); } - const foregroundClaims = params.entry?.mainRestartRecovery?.foregroundClaims; - const foregroundOwnerClaimId = - runId && - lifecycleGeneration && - lifecycleGeneration === params.currentLifecycleGeneration && - foregroundClaims?.lifecycleGeneration === lifecycleGeneration - ? foregroundClaims.tokens.find( - (claimId) => foregroundClaims.runIdsByClaimId?.[claimId] === runId, - ) - : undefined; - const remainingForegroundClaimIds = foregroundOwnerClaimId - ? foregroundClaims!.tokens.filter((claimId) => claimId !== foregroundOwnerClaimId) - : foregroundClaims?.tokens; - const remainingForegroundRunIds = foregroundOwnerClaimId - ? Object.fromEntries( - Object.entries(foregroundClaims?.runIdsByClaimId ?? {}).filter( - ([claimId]) => claimId !== foregroundOwnerClaimId, - ), - ) - : foregroundClaims?.runIdsByClaimId; - const remainingForegroundClaims = remainingForegroundClaimIds?.length - ? { - lifecycleGeneration: foregroundClaims!.lifecycleGeneration, - tokens: remainingForegroundClaimIds, - ...(remainingForegroundRunIds && Object.keys(remainingForegroundRunIds).length > 0 - ? { runIdsByClaimId: remainingForegroundRunIds } - : {}), - } - : undefined; - const recoveryStateAfterForegroundSettlement = foregroundOwnerClaimId - ? { - ...params.entry!.mainRestartRecovery!, - revision: params.entry!.mainRestartRecovery!.revision + 1, - foregroundClaims: remainingForegroundClaims, - } - : params.entry?.mainRestartRecovery; - const hasForegroundOwners = Boolean( - remainingForegroundClaims?.lifecycleGeneration === params.currentLifecycleGeneration && - remainingForegroundClaims.tokens.length, + const foreground = settleForegroundOwner( + params.entry ?? {}, + runId, + lifecycleGeneration, + params.currentLifecycleGeneration, ); - const reservation = params.entry?.mainRestartRecovery?.reservation; - const hasCurrentReservation = - reservation?.lifecycleGeneration === params.currentLifecycleGeneration; - const hasCurrentOwner = hasForegroundOwners || hasCurrentReservation; - if (!matchesFence) { - // No terminal snapshot may settle a recovery row it cannot identify. - return params.entry?.mainRestartRecovery || runs?.length - ? { action: "suppress" } - : { action: "apply", patch }; - } - if (hasCurrentOwner) { + if (foreground.hasCurrentOwner) { // A terminal event may consume its own claim. Another owner still keeps // the aggregate live until that owner's terminal event or release. - return { - action: "apply", - patch: { - restartRecoveryRuns: remaining?.length ? remaining : undefined, - restartRecoveryTerminalRunIds: mergeRestartRecoveryTerminalRunIds( - params.entry?.restartRecoveryTerminalRunIds, - [runId], - ), - ...(foregroundOwnerClaimId - ? { mainRestartRecovery: recoveryStateAfterForegroundSettlement } - : {}), - }, - }; + return apply({ + restartRecoveryRuns: remaining?.length ? remaining : undefined, + restartRecoveryTerminalRunIds: mergeRestartRecoveryTerminalRunIds( + params.entry?.restartRecoveryTerminalRunIds, + [runId], + ), + ...(foreground.claimId ? { mainRestartRecovery: foreground.state } : {}), + }); } - if (foregroundOwnerClaimId) { + if (foreground.claimId) { // This exact foreground run completed while its release lease was still // active. Its terminal snapshot is authoritative and consumes the cycle. Object.assign(patch, buildMainSessionRecoveryClearPatch(params.entry)); - return { action: "apply", patch }; + return apply(patch); } - if ( - !hasForegroundOwners && - !hasCurrentReservation && - params.entry?.abortedLastRun === true && - (remaining?.length ?? 0) > 0 - ) { - return { action: "apply", patch: { restartRecoveryRuns: remaining } }; + if (params.entry?.abortedLastRun === true && (remaining?.length ?? 0) > 0) { + return apply({ restartRecoveryRuns: remaining }); } const recoveryDeliveryRunId = typeof params.entry?.restartRecoveryDeliveryRunId === "string" @@ -216,19 +226,19 @@ export function projectMainSessionRecoveryLifecycle(params: { patch.abortedLastRun = false; patch.restartRecoveryRuns = remaining; patch.mainRestartRecovery = params.entry?.mainRestartRecovery; - return { action: "apply", patch }; + return apply(patch); } // An admitted recovery clears the interruption flag before it runs. With // no live owner left, that exact delivery run is the durable cleanup boundary. Object.assign(patch, buildMainSessionRecoveryClearPatch(params.entry)); - return { action: "apply", patch }; + return apply(patch); } if (phase === "start" || !matchesFence || !remaining) { - return { action: "apply", patch }; + return apply(patch); } if (params.entry?.abortedLastRun === true && remaining.length > 0) { - return { action: "apply", patch: { restartRecoveryRuns: remaining } }; + return apply({ restartRecoveryRuns: remaining }); } patch.restartRecoveryRuns = remaining.length > 0 ? remaining : undefined; - return { action: "apply", patch }; + return apply(patch); } diff --git a/src/agents/main-session-recovery-owner-release.ts b/src/agents/main-session-recovery-owner-release.ts index 2963f9e2bcc2..d301936225a4 100644 --- a/src/agents/main-session-recovery-owner-release.ts +++ b/src/agents/main-session-recovery-owner-release.ts @@ -1,5 +1,3 @@ -import { getRuntimeConfig } from "../config/io.js"; -import { getGatewayRecoveryRuntime } from "../gateway/server-recovery-runtime-context.js"; import type { MainSessionRecoveryPendingTarget } from "./main-session-recovery-store.js"; /** Schedules exact-row recovery only after the caller releases its lifecycle admission. */ @@ -10,17 +8,16 @@ export function scheduleMainSessionRecoveryPendingTarget( return; } void import("./main-session-restart-recovery.js").then( - ({ scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease }) => { - scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease({ + ({ scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease: schedule }) => + schedule({ expectedSessionId: target.sessionId, getConfig: getRuntimeConfig, getGatewayRuntime: getGatewayRecoveryRuntime, sessionKey: target.sessionKey, storePath: target.storePath, - }); - }, - () => { - // Startup recovery remains the fallback if the optional recovery module cannot load. - }, + }), + () => {}, // Startup recovery remains the fallback if this optional module cannot load. ); } +import { getRuntimeConfig } from "../config/io.js"; +import { getGatewayRecoveryRuntime } from "../gateway/server-recovery-runtime-context.js"; diff --git a/src/agents/main-session-recovery-restore.ts b/src/agents/main-session-recovery-restore.ts deleted file mode 100644 index f80731ab0c7c..000000000000 --- a/src/agents/main-session-recovery-restore.ts +++ /dev/null @@ -1,36 +0,0 @@ -import { formatErrorMessage } from "../infra/errors.js"; -import { retryAsync } from "../infra/retry.js"; -import { createSubsystemLogger } from "../logging/subsystem.js"; -import { scheduleMainSessionRecoveryPendingTarget } from "./main-session-recovery-owner-release.js"; -import type { MainSessionRecoveryPendingTarget } from "./main-session-recovery-store.js"; - -const log = createSubsystemLogger("main-session-recovery"); -const RESTORE_RETRY_DELAY_MS = 1_000; -const RESTORE_RETRY_MAX_DELAY_MS = 30_000; - -export type RestoreAdmittedRecovery = () => Promise; - -export async function restoreAdmittedRecoveryWithRetries( - restore: RestoreAdmittedRecovery, -): Promise { - return await retryAsync(restore, 3, 25); -} - -export function scheduleAdmittedRecoveryRestore( - restore: RestoreAdmittedRecovery, - delayMs = RESTORE_RETRY_DELAY_MS, -): void { - // Gateway admission consumed the reservation already. Keep restoration - // alive until this exact idempotent callback repairs or rejects its fence. - setTimeout(() => { - void restoreAdmittedRecoveryWithRetries(restore).then( - (pendingRecovery) => { - scheduleMainSessionRecoveryPendingTarget(pendingRecovery); - }, - (error: unknown) => { - log.warn(`failed delayed admitted recovery restoration: ${formatErrorMessage(error)}`); - scheduleAdmittedRecoveryRestore(restore, Math.min(delayMs * 2, RESTORE_RETRY_MAX_DELAY_MS)); - }, - ); - }, delayMs).unref?.(); -} diff --git a/src/agents/main-session-recovery-state.test.ts b/src/agents/main-session-recovery-state.test.ts index 58a529f057f7..4b8ef1e8b404 100644 --- a/src/agents/main-session-recovery-state.test.ts +++ b/src/agents/main-session-recovery-state.test.ts @@ -4,10 +4,7 @@ import type { MainRestartRecoveryState, } from "../config/sessions.js"; import { buildMainSessionRecoveryClearPatch } from "./main-session-recovery-clear.js"; -import { - inspectMainSessionRecoveryHealth, - projectMainSessionRecoveryLifecycle, -} from "./main-session-recovery-lifecycle.js"; +import { projectMainSessionRecoveryLifecycle } from "./main-session-recovery-lifecycle.js"; import { transitionMainSessionRecovery } from "./main-session-recovery-state.js"; const sessionKey = "agent:main:main"; @@ -701,18 +698,10 @@ describe("main session recovery state", () => { reason: view.reason, }), ).toEqual({ kind: "tombstoned" }); - expect(inspectMainSessionRecoveryHealth(entry)).toEqual({ - status: "tombstoned", - reason: view.reason, - repair: null, - }); + expect(entry.mainRestartRecovery?.tombstone?.reason).toBe(view.reason); expect(observe(entry, "generation-1")).toEqual({ status: "tombstoned" }); entry.abortedLastRun = true; - expect(inspectMainSessionRecoveryHealth(entry)).toMatchObject({ - status: "tombstoned", - repair: "clear_stale_abort", - }); expect(transitionMainSessionRecovery(entry, { kind: "doctor_repair", now: 500 })).toEqual({ kind: "doctor_repaired", }); diff --git a/src/agents/main-session-recovery-state.ts b/src/agents/main-session-recovery-state.ts index de54fc8b5a5b..95d7a0e168eb 100644 --- a/src/agents/main-session-recovery-state.ts +++ b/src/agents/main-session-recovery-state.ts @@ -17,6 +17,7 @@ import type { MainSessionRecoveryTransitionResult, MainSessionRecoveryView, } from "./main-session-recovery-types.js"; +import { MAX_RECOVERY_RETRIES } from "./main-session-restart-recovery-shared.js"; export type { MainSessionRecoveryCommand, @@ -26,13 +27,15 @@ export type { MainSessionRecoveryTransitionResult, } from "./main-session-recovery-types.js"; -const MAIN_RESTART_RECOVERY_MAX_AUTOMATIC_ATTEMPTS = 3; - const MAIN_RESTART_RECOVERY_REMEDIATION_HINT = "inspect the failed main session and use /new or reset to start a replacement session"; -function nextRevision(state: MainRestartRecoveryState): number { - return state.revision + 1; +function updateRecoveryState( + entry: SessionEntry, + state: MainRestartRecoveryState, + patch: Omit, "revision">, +): MainRestartRecoveryState { + return (entry.mainRestartRecovery = { ...state, revision: state.revision + 1, ...patch }); } function createCycle(cycleId: string): MainRestartRecoveryState { @@ -43,18 +46,6 @@ function createCycle(cycleId: string): MainRestartRecoveryState { }; } -function observationFor(entry: SessionEntry): MainSessionRecoveryObservation | undefined { - const state = entry.mainRestartRecovery; - if (!state) { - return undefined; - } - return { - sessionId: entry.sessionId, - cycleId: state.cycleId, - revision: state.revision, - }; -} - function matchesObservation( entry: SessionEntry, observation: MainSessionRecoveryObservation, @@ -78,6 +69,17 @@ function hasCurrentForegroundClaim( ); } +function ownsForegroundClaim( + state: MainRestartRecoveryState | undefined, + claim: { cycleId: string; lifecycleGeneration: string; claimId: string }, +): boolean { + return ( + state?.cycleId === claim.cycleId && + state.foregroundClaims?.lifecycleGeneration === claim.lifecycleGeneration && + state.foregroundClaims.tokens.includes(claim.claimId) + ); +} + function validateRecoveryAdmission( entry: SessionEntry, command: { @@ -106,12 +108,8 @@ function validateRecoveryAdmission( export function normalizeMainSessionRecoveryRunFences( runs: Iterable, ): RestartRecoveryRun[] { - const ownersByRunId = new Map(); - for (const run of runs) { - ownersByRunId.set(run.runId, run); - } - return [...ownersByRunId.values()].toSorted((left, right) => - left.runId.localeCompare(right.runId), + return [...new Map([...runs].map((run) => [run.runId, run] as const)).values()].toSorted( + (left, right) => left.runId.localeCompare(right.runId), ); } @@ -124,31 +122,6 @@ function recordLifecycleFence(entry: SessionEntry, run: RestartRecoveryRun): voi ]); } -function hasLifecycleFence(entry: SessionEntry, run: RestartRecoveryRun): boolean { - return Boolean( - entry.restartRecoveryRuns?.some( - (candidate) => - candidate.runId === run.runId && candidate.lifecycleGeneration === run.lifecycleGeneration, - ), - ); -} - -function formatAttemptBudgetReason(attempts: number): string { - return ( - `main-session restart recovery blocked after ${attempts} charged automatic resume attempts; ` + - MAIN_RESTART_RECOVERY_REMEDIATION_HINT - ); -} - -export function isMainSessionRecoveryExhausted(entry: SessionEntry): boolean { - return ( - entry.status === "running" && - entry.abortedLastRun === true && - (entry.mainRestartRecovery?.chargedAttempts ?? 0) >= - MAIN_RESTART_RECOVERY_MAX_AUTOMATIC_ATTEMPTS - ); -} - export function isMainRestartRecoveryCandidate(entry: SessionEntry, sessionKey: string): boolean { if (typeof entry.spawnDepth === "number" && entry.spawnDepth > 0) { return false; @@ -163,6 +136,18 @@ export function isMainRestartRecoveryCandidate(entry: SessionEntry, sessionKey: ); } +export function isMainSessionRecoveryPending(entry: SessionEntry, sessionKey: string): boolean { + const state = entry.mainRestartRecovery; + return ( + entry.status === "running" && + entry.abortedLastRun === true && + isMainRestartRecoveryCandidate(entry, sessionKey) && + !state?.foregroundClaims && + !state?.reservation && + !state?.tombstone + ); +} + // A healthy session can retain lifecycle fences after its final recovery owner // clears. With no active delivery or aggregate, those fences no longer own work. function hasOrphanedMainRestartRecoveryFences(entry: SessionEntry, sessionKey: string): boolean { @@ -216,18 +201,24 @@ function inspectMainSessionRecovery(params: { ) { return { status: "inactive" }; } - const observation = observationFor(entry); - if (!state || !observation) { + if (!state) { return { status: "inactive" }; } + const observation = { + sessionId: entry.sessionId, + cycleId: state.cycleId, + revision: state.revision, + }; if (state.reservation) { return { status: "blocked" }; } - if (state.chargedAttempts >= MAIN_RESTART_RECOVERY_MAX_AUTOMATIC_ATTEMPTS) { + if (state.chargedAttempts >= MAX_RECOVERY_RETRIES) { return { status: "exhausted", observation, - reason: formatAttemptBudgetReason(state.chargedAttempts), + reason: + `main-session restart recovery blocked after ${state.chargedAttempts} charged automatic resume attempts; ` + + MAIN_RESTART_RECOVERY_REMEDIATION_HINT, }; } return { @@ -318,11 +309,7 @@ export function transitionMainSessionRecovery( Object.assign(entry, buildMainSessionRecoveryClearPatch(entry)); state = undefined; } else { - entry.mainRestartRecovery = state = { - ...state, - revision: nextRevision(state), - foregroundClaims: undefined, - }; + state = updateRecoveryState(entry, state, { foregroundClaims: undefined }); } } if ( @@ -331,11 +318,7 @@ export function transitionMainSessionRecovery( ) { // A process restart makes dispatch outcome unknowable: retain the charge, // but release the stale slot so the next bounded attempt can proceed. - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), - reservation: undefined, - }; + updateRecoveryState(entry, state, { reservation: undefined }); } return { kind: "observed", @@ -364,16 +347,14 @@ export function transitionMainSessionRecovery( if (command.attempt !== state.chargedAttempts + 1) { return { kind: "rejected", reason: "stale_revision" }; } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { chargedAttempts: command.attempt, reservation: { runId: command.runId, attempt: command.attempt, lifecycleGeneration: command.lifecycleGeneration, }, - }; + }); entry.updatedAt = command.now; return { kind: "reserved", @@ -400,15 +381,13 @@ export function transitionMainSessionRecovery( ) { return { kind: "rejected", reason: "stale_reservation" }; } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { chargedAttempts: command.kind === "cancel_reservation" ? Math.max(0, command.reservation.attempt - 1) : state.chargedAttempts, reservation: undefined, - }; + }); return { kind: "applied" }; } case "validate_recovery": { @@ -421,12 +400,10 @@ export function transitionMainSessionRecovery( return { kind: "rejected", reason: conflict }; } const state = entry.mainRestartRecovery!; - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { reservation: undefined, foregroundClaims: undefined, - }; + }); entry.abortedLastRun = false; recordLifecycleFence(entry, { runId: command.runId, @@ -450,10 +427,10 @@ export function transitionMainSessionRecovery( if ( !state || state.reservation || - !hasLifecycleFence(entry, { - runId: command.runId, - lifecycleGeneration: command.lifecycleGeneration, - }) + !entry.restartRecoveryRuns?.some( + (run) => + run.runId === command.runId && run.lifecycleGeneration === command.lifecycleGeneration, + ) ) { return { kind: "rejected", reason: "stale_reservation" }; } @@ -490,7 +467,7 @@ export function transitionMainSessionRecovery( if (state.tombstone) { return { kind: "rejected", reason: "already_tombstoned" }; } - if (state.chargedAttempts >= MAIN_RESTART_RECOVERY_MAX_AUTOMATIC_ATTEMPTS) { + if (state.chargedAttempts >= MAX_RECOVERY_RETRIES) { // The final charge fences foreground work until the scheduler commits // the matching tombstone. Admitting here can race that reconciliation. return { kind: "rejected", reason: "recovery_exhausted" }; @@ -513,9 +490,7 @@ export function transitionMainSessionRecovery( runId: command.runId, }); } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { reservation: state.reservation?.lifecycleGeneration === command.lifecycleGeneration ? state.reservation @@ -525,7 +500,7 @@ export function transitionMainSessionRecovery( tokens, ...(runIdsByClaimId ? { runIdsByClaimId } : {}), }, - }; + }); return { kind: "foreground_claimed", claim: { @@ -541,47 +516,32 @@ export function transitionMainSessionRecovery( case "bind_foreground_run": { const state = entry.mainRestartRecovery; const claims = state?.foregroundClaims; - if ( - !state || - state.cycleId !== command.claim.cycleId || - claims?.lifecycleGeneration !== command.claim.lifecycleGeneration || - !claims.tokens.includes(command.claim.claimId) - ) { + if (!state || !claims || !ownsForegroundClaim(state, command.claim)) { return { kind: "no_change" }; } recordLifecycleFence(entry, { lifecycleGeneration: command.claim.lifecycleGeneration, runId: command.runId, }); - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { foregroundClaims: { ...claims, runIdsByClaimId: { ...claims.runIdsByClaimId, [command.claim.claimId]: command.runId }, }, - }; + }); return { kind: "applied" }; } case "validate_foreground": { const state = entry.mainRestartRecovery; - const claims = state?.foregroundClaims; return entry.sessionId === command.claim.sessionId && - state?.cycleId === command.claim.cycleId && - claims?.lifecycleGeneration === command.claim.lifecycleGeneration && - claims.tokens.includes(command.claim.claimId) + ownsForegroundClaim(state, command.claim) ? { kind: "foreground_validated" } : { kind: "no_change" }; } case "release_foreground": { const state = entry.mainRestartRecovery; const claims = state?.foregroundClaims; - if ( - !state || - state.cycleId !== command.claim.cycleId || - claims?.lifecycleGeneration !== command.claim.lifecycleGeneration || - !claims.tokens.includes(command.claim.claimId) - ) { + if (!state || !claims || !ownsForegroundClaim(state, command.claim)) { return { kind: "no_change" }; } const tokens = claims.tokens.filter((token) => token !== command.claim.claimId); @@ -594,9 +554,7 @@ export function transitionMainSessionRecovery( Object.assign(entry, buildMainSessionRecoveryClearPatch(entry)); return { kind: "applied" }; } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { foregroundClaims: tokens.length > 0 ? { @@ -605,7 +563,7 @@ export function transitionMainSessionRecovery( ...(Object.keys(runIdsByClaimId).length > 0 ? { runIdsByClaimId } : {}), } : undefined, - }; + }); return { kind: "applied" }; } case "tombstone": { @@ -620,13 +578,11 @@ export function transitionMainSessionRecovery( if (state.tombstone) { return { kind: "rejected", reason: "already_tombstoned" }; } - entry.mainRestartRecovery = { - ...state, - revision: nextRevision(state), + updateRecoveryState(entry, state, { tombstone: { reason: command.reason, }, - }; + }); entry.abortedLastRun = false; entry.status = "failed"; entry.endedAt = command.now; diff --git a/src/agents/main-session-recovery-store.test.ts b/src/agents/main-session-recovery-store.test.ts index ef8b7320c38f..c4eeb72f3c91 100644 --- a/src/agents/main-session-recovery-store.test.ts +++ b/src/agents/main-session-recovery-store.test.ts @@ -15,7 +15,7 @@ import { claimMainSessionRecoveryOwner, commitMainSessionRecovery, inspectMainSessionRecoveryRequired, - readMainSessionRecoveryOwner, + refreshMainSessionRecoveryOwner, releaseMainSessionRecoveryOwner, } from "./main-session-recovery-store.js"; @@ -516,9 +516,9 @@ describe("main session recovery store", () => { throw new Error("expected foreground owner claim"); } - await expect(readMainSessionRecoveryOwner(claim.lease)).resolves.toBeDefined(); + await expect(refreshMainSessionRecoveryOwner(claim.lease)).resolves.toBeDefined(); await releaseMainSessionRecoveryOwner(claim.lease); - await expect(readMainSessionRecoveryOwner(claim.lease)).resolves.toBeUndefined(); + await expect(refreshMainSessionRecoveryOwner(claim.lease)).resolves.toBeUndefined(); }); it("returns a retry target only when the final foreground owner releases", async () => { @@ -735,6 +735,6 @@ describe("main session recovery store", () => { } rotateAgentEventLifecycleGeneration(); - await expect(readMainSessionRecoveryOwner(claim.lease)).resolves.toBeUndefined(); + await expect(refreshMainSessionRecoveryOwner(claim.lease)).resolves.toBeUndefined(); }); }); diff --git a/src/agents/main-session-recovery-store.ts b/src/agents/main-session-recovery-store.ts index a5e9c0e6dcd6..350463345982 100644 --- a/src/agents/main-session-recovery-store.ts +++ b/src/agents/main-session-recovery-store.ts @@ -2,9 +2,13 @@ import { randomUUID } from "node:crypto"; import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; import { applySessionEntryReplacements } from "../config/sessions/session-accessor.js"; import { getAgentEventLifecycleGeneration } from "../infra/agent-events.js"; -import { retryAsync } from "../infra/retry.js"; +import { + retryMainSessionRecoveryMutation, + scheduleMainSessionRecoveryMutation, +} from "./main-session-recovery-lifecycle.js"; import { isMainRestartRecoveryCandidate, + isMainSessionRecoveryPending, transitionMainSessionRecovery, type MainSessionRecoveryCommand, type MainSessionRecoveryOwnerClaim, @@ -17,9 +21,6 @@ type MainSessionRecoveryStoreTarget = { storePath: string; }; -const OWNER_RELEASE_RETRY_DELAY_MS = 1_000; -const OWNER_RELEASE_RETRY_MAX_DELAY_MS = 30_000; - export type MainSessionRecoveryOwnerLease = MainSessionRecoveryOwnerClaim & MainSessionRecoveryStoreTarget; @@ -33,30 +34,6 @@ export type MainSessionRecoveryPendingTarget = MainSessionRecoveryStoreTarget & sessionId: string; }; -type MainSessionRecoveryOwnerClaimResult = - | { - kind: "claimed"; - lease: MainSessionRecoveryOwnerLease; - entry: SessionEntry; - sessionKey: string; - } - | { kind: "invalidated"; reason: string } - | { kind: "not_required" }; - -type MainSessionRecoveryInspectionResult = - | { kind: "invalidated"; reason: string } - | { kind: "not_required" } - | { kind: "required" }; - -function transitionChanged(result: MainSessionRecoveryTransitionResult): boolean { - return ( - result.kind !== "foreground_validated" && - result.kind !== "no_change" && - result.kind !== "observed" && - result.kind !== "rejected" - ); -} - function matchesReservation(entry: SessionEntry, reservation: MainSessionRecoveryReservation) { const state = entry.mainRestartRecovery; return ( @@ -67,45 +44,13 @@ function matchesReservation(entry: SessionEntry, reservation: MainSessionRecover ); } -function matchesRecoveryAdmission( - entry: SessionEntry, - command: Extract, -): boolean { - const reservation = entry.mainRestartRecovery?.reservation; - return ( - entry.sessionId === command.sessionId && - reservation?.runId === command.runId && - reservation.lifecycleGeneration === command.lifecycleGeneration - ); -} - -function matchesOwnerClaim(entry: SessionEntry, claim: MainSessionRecoveryOwnerClaim): boolean { - const state = entry.mainRestartRecovery; - return ( - state?.cycleId === claim.cycleId && - state.foregroundClaims?.lifecycleGeneration === claim.lifecycleGeneration && - state.foregroundClaims.tokens.includes(claim.claimId) - ); -} - function currentGenerationRequiredBy(command: MainSessionRecoveryCommand): string | undefined { // Generation gates new decisions. Exact reservation/token cleanup must remain // valid after a restart so the old owner cannot leak its slot or claim. - switch (command.kind) { - case "admit_recovery": - case "claim_foreground": - case "inspect": - case "mark_admitted_recovery_interrupted": - case "observe": - case "prepare_attempt": - case "validate_recovery": - return command.lifecycleGeneration; - case "validate_foreground": - case "bind_foreground_run": - return command.claim.lifecycleGeneration; - default: - return undefined; + if (command.kind === "validate_foreground" || command.kind === "bind_foreground_run") { + return command.claim.lifecycleGeneration; } + return "lifecycleGeneration" in command ? command.lifecycleGeneration : undefined; } export async function commitMainSessionRecovery(params: { @@ -116,26 +61,21 @@ export async function commitMainSessionRecovery(params: { shouldContinue?: () => boolean; target: MainSessionRecoveryStoreTarget; }): Promise { - const cancellation = - params.command.kind === "cancel_reservation" ? params.command.reservation : undefined; - const abandonment = - params.command.kind === "abandon_reservation" ? params.command.reservation : undefined; + const reservationCleanup = + params.command.kind === "cancel_reservation" || params.command.kind === "abandon_reservation" + ? params.command.reservation + : undefined; const recoveryAdmission = params.command.kind === "admit_recovery" || params.command.kind === "validate_recovery" ? params.command : undefined; const ownerClaim = params.command.kind === "claim_foreground" ? params.command : undefined; - const ownerValidation = - params.command.kind === "validate_foreground" ? params.command.claim : undefined; - const ownerRelease = - params.command.kind === "release_foreground" ? params.command.claim : undefined; - const reservationCleanup = cancellation ?? abandonment; + const exactOwnerClaim = + params.command.kind === "validate_foreground" || params.command.kind === "release_foreground" + ? params.command.claim + : undefined; const scansAliases = Boolean( - params.scanAliases || - reservationCleanup || - recoveryAdmission || - ownerValidation || - ownerRelease, + params.scanAliases || reservationCleanup || recoveryAdmission || exactOwnerClaim, ); return await applySessionEntryReplacements({ requireWriteSuccess: params.requireWriteSuccess, @@ -172,11 +112,24 @@ export async function commitMainSessionRecovery(params: { // Canonical session-key migration may happen between reservation and // Gateway admission; the reservation identity remains authoritative. candidate = - entries.find(({ entry }) => matchesRecoveryAdmission(entry, recoveryAdmission)) ?? - selected; - } else if (ownerValidation || ownerRelease) { - const exactClaim = ownerValidation ?? ownerRelease!; - candidate = entries.find(({ entry }) => matchesOwnerClaim(entry, exactClaim)) ?? selected; + entries.find(({ entry }) => { + const reservation = (entry as SessionEntry).mainRestartRecovery?.reservation; + return ( + entry.sessionId === recoveryAdmission.sessionId && + reservation?.runId === recoveryAdmission.runId && + reservation.lifecycleGeneration === recoveryAdmission.lifecycleGeneration + ); + }) ?? selected; + } else if (exactOwnerClaim) { + candidate = + entries.find(({ entry }) => { + const state = (entry as SessionEntry).mainRestartRecovery; + return ( + state?.cycleId === exactOwnerClaim.cycleId && + state.foregroundClaims?.lifecycleGeneration === exactOwnerClaim.lifecycleGeneration && + state.foregroundClaims.tokens.includes(exactOwnerClaim.claimId) + ); + }) ?? selected; } else if (ownerClaim && (!selected || selected.entry.sessionId !== ownerClaim.sessionId)) { candidate = entries.find(({ entry }) => entry.sessionId === ownerClaim.sessionId); } else if (params.scanAliases && params.expectedSessionId) { @@ -209,7 +162,11 @@ export async function commitMainSessionRecovery(params: { } const transition = transitionMainSessionRecovery(entry, command); const changed = - transitionChanged(transition) || previousRecoveryState !== entry.mainRestartRecovery; + previousRecoveryState !== entry.mainRestartRecovery || + (transition.kind !== "foreground_validated" && + transition.kind !== "no_change" && + transition.kind !== "observed" && + transition.kind !== "rejected"); return { result: { entry, sessionKey: candidate.sessionKey, transition }, ...(changed ? { replacements: [{ sessionKey: candidate.sessionKey, entry }] } : {}), @@ -218,16 +175,28 @@ export async function commitMainSessionRecovery(params: { }); } -export async function readMainSessionRecoveryOwner( +export async function refreshMainSessionRecoveryOwner( lease: MainSessionRecoveryOwnerLease, -): Promise<{ entry: SessionEntry; sessionKey: string } | undefined> { + runId?: string, +): Promise< + { lease: MainSessionRecoveryOwnerLease; entry: SessionEntry; sessionKey: string } | undefined +> { const result = await commitMainSessionRecovery({ - command: { kind: "validate_foreground", claim: lease }, + command: runId + ? { kind: "bind_foreground_run", claim: lease, runId } + : { kind: "validate_foreground", claim: lease }, requireWriteSuccess: true, target: lease, }); - return result.transition.kind === "foreground_validated" && result.entry && result.sessionKey - ? { entry: result.entry, sessionKey: result.sessionKey } + const accepted = runId + ? result.transition.kind === "applied" + : result.transition.kind === "foreground_validated"; + return accepted && result.entry && result.sessionKey + ? { + lease: runId ? { ...lease, runId } : lease, + entry: result.entry, + sessionKey: result.sessionKey, + } : undefined; } @@ -238,7 +207,7 @@ export async function claimMainSessionRecoveryOwner(params: { sessionId: string; runId?: string; target: MainSessionRecoveryStoreTarget; -}): Promise { +}) { const command = { kind: "claim_foreground" as const, cycleId: randomUUID(), @@ -263,70 +232,44 @@ export async function claimMainSessionRecoveryOwner(params: { } if (claim.transition.kind === "foreground_claimed") { if (!claim.entry || !claim.sessionKey) { - return { kind: "invalidated", reason: "state_changed" }; + return { kind: "invalidated", reason: "state_changed" } as const; } return { kind: "claimed", lease: { ...claim.transition.claim, storePath: params.target.storePath }, entry: claim.entry, sessionKey: claim.sessionKey, - }; + } as const; } if (claim.transition.kind === "rejected" && claim.transition.reason === "stale_generation") { - return { kind: "invalidated", reason: claim.transition.reason }; + return { kind: "invalidated", reason: claim.transition.reason } as const; } if (!claim.entry && (params.allowMissingSession || params.replacementSessionId)) { // A fresh explicit session has no predecessor. An automatic rollover can // also lose its predecessor before admission. Either way, no row remains to fence. - return { kind: "not_required" }; + return { kind: "not_required" } as const; } - if ( - params.replacementSessionId && - claim.entry?.sessionId === params.replacementSessionId && + const healthyExpectedSession = + claim.entry && claim.entry.abortedLastRun !== true && claim.entry.restartRecoveryRuns === undefined && - claim.entry.mainRestartRecovery === undefined - ) { - return { kind: "not_required" }; - } + claim.entry.mainRestartRecovery === undefined && + (claim.entry.sessionId === params.sessionId || + claim.entry.sessionId === params.replacementSessionId); if ( claim.entry?.sessionId === params.sessionId && claim.sessionKey && !isMainRestartRecoveryCandidate(claim.entry, claim.sessionKey) ) { - return { kind: "not_required" }; + return { kind: "not_required" } as const; } - if ( - claim.entry?.sessionId === params.sessionId && - claim.entry.abortedLastRun !== true && - claim.entry.restartRecoveryRuns === undefined && - claim.entry.mainRestartRecovery === undefined - ) { + if (healthyExpectedSession) { // A healthy completion may clear recovery between the caller's read and this // transaction. Only that fully clean same-session state can proceed unclaimed. - return { kind: "not_required" }; + return { kind: "not_required" } as const; } const reason = claim.transition.kind === "rejected" ? claim.transition.reason : "state_changed"; - return { kind: "invalidated", reason }; -} - -export async function bindMainSessionRecoveryOwnerRun( - lease: MainSessionRecoveryOwnerLease, - runId: string, -): Promise<{ - lease: MainSessionRecoveryOwnerLease; - entry: SessionEntry; - sessionKey: string; -}> { - const result = await commitMainSessionRecovery({ - command: { kind: "bind_foreground_run", claim: lease, runId }, - requireWriteSuccess: true, - target: lease, - }); - if (result.transition.kind !== "applied" || !result.entry || !result.sessionKey) { - throw new Error("main-session recovery owner changed before run binding"); - } - return { lease: { ...lease, runId }, entry: result.entry, sessionKey: result.sessionKey }; + return { kind: "invalidated", reason } as const; } export async function inspectMainSessionRecoveryRequired(params: { @@ -334,7 +277,7 @@ export async function inspectMainSessionRecoveryRequired(params: { expectedSessionId: string; lifecycleGeneration: string; target: MainSessionRecoveryStoreTarget; -}): Promise { +}) { const command = { kind: "inspect" as const, lifecycleGeneration: params.lifecycleGeneration, @@ -376,59 +319,39 @@ async function releaseMainSessionRecoveryOwnerWithRetries( ): Promise { // A leaked current-generation token blocks automatic recovery until restart. // Token-scoped release is idempotent, so transient writer failures are safe to retry. - const released = await retryAsync( - async () => - await commitMainSessionRecovery({ - command: { kind: "release_foreground", claim: lease }, - requireWriteSuccess: true, - target: lease, - }), - 3, - 25, + const released = await retryMainSessionRecoveryMutation(async () => + commitMainSessionRecovery({ + command: { kind: "release_foreground", claim: lease }, + requireWriteSuccess: true, + target: lease, + }), ); const { entry, sessionKey } = released; - const state = entry?.mainRestartRecovery; if ( (released.transition.kind !== "applied" && released.transition.kind !== "no_change") || !entry || !sessionKey || entry.sessionId !== lease.sessionId || - entry.status !== "running" || - entry.abortedLastRun !== true || - !isMainRestartRecoveryCandidate(entry, sessionKey) || - state?.foregroundClaims || - state?.reservation || - state?.tombstone + !isMainSessionRecoveryPending(entry, sessionKey) ) { return undefined; } return { sessionId: entry.sessionId, sessionKey, storePath: lease.storePath }; } -function scheduleMainSessionRecoveryOwnerRelease( - lease: MainSessionRecoveryOwnerLease, - delayMs = OWNER_RELEASE_RETRY_DELAY_MS, -): void { +function scheduleMainSessionRecoveryOwnerRelease(lease: MainSessionRecoveryOwnerLease): void { // A token is process-owned but durably blocks recovery. Keep exact-token // cleanup alive through transient writer outages until release or restart. - setTimeout(() => { - void releaseMainSessionRecoveryOwnerWithRetries(lease).then( - async (pending) => { - if (!pending) { - return; - } + scheduleMainSessionRecoveryMutation({ + mutation: () => releaseMainSessionRecoveryOwnerWithRetries(lease), + onSuccess: async (pending) => { + if (pending) { const { scheduleMainSessionRecoveryPendingTarget } = await import("./main-session-recovery-owner-release.js"); scheduleMainSessionRecoveryPendingTarget(pending); - }, - () => { - scheduleMainSessionRecoveryOwnerRelease( - lease, - Math.min(delayMs * 2, OWNER_RELEASE_RETRY_MAX_DELAY_MS), - ); - }, - ); - }, delayMs).unref?.(); + } + }, + }); } export async function releaseMainSessionRecoveryOwner( diff --git a/src/agents/main-session-recovery-types.ts b/src/agents/main-session-recovery-types.ts index 3cbf089a113f..7a86dd6f52e1 100644 --- a/src/agents/main-session-recovery-types.ts +++ b/src/agents/main-session-recovery-types.ts @@ -53,6 +53,12 @@ export type MainSessionRecoveryConflict = | "stale_reservation" | "stale_revision"; +type RecoveryRunOwner = { + lifecycleGeneration: string; + runId: string; + sessionId: string; +}; + export type MainSessionRecoveryCommand = | { kind: "mark_interrupted"; @@ -80,28 +86,15 @@ export type MainSessionRecoveryCommand = observation: MainSessionRecoveryObservation; runId: string; } - | { kind: "cancel_reservation"; reservation: MainSessionRecoveryReservation } - | { kind: "abandon_reservation"; reservation: MainSessionRecoveryReservation } | { - kind: "validate_recovery"; - lifecycleGeneration: string; - runId: string; - sessionId: string; + kind: "cancel_reservation" | "abandon_reservation"; + reservation: MainSessionRecoveryReservation; } - | { - kind: "admit_recovery"; - lifecycleGeneration: string; + | ({ kind: "validate_recovery" } & RecoveryRunOwner) + | ({ + kind: "admit_recovery" | "mark_admitted_recovery_interrupted"; now: number; - runId: string; - sessionId: string; - } - | { - kind: "mark_admitted_recovery_interrupted"; - lifecycleGeneration: string; - now: number; - runId: string; - sessionId: string; - } + } & RecoveryRunOwner) | { kind: "claim_foreground"; cycleId: string; @@ -129,15 +122,18 @@ export type MainSessionRecoveryCommand = | { kind: "clear" }; export type MainSessionRecoveryTransitionResult = - | { kind: "admitted_recovery" } - | { kind: "applied" } - | { kind: "doctor_repaired" } + | { + kind: + | "admitted_recovery" + | "applied" + | "doctor_repaired" + | "foreground_validated" + | "no_change" + | "recovery_validated" + | "tombstoned"; + } | { kind: "failed"; noticeEntry: SessionEntry } | { kind: "foreground_claimed"; claim: MainSessionRecoveryOwnerClaim } - | { kind: "foreground_validated" } - | { kind: "no_change" } | { kind: "observed"; view: MainSessionRecoveryView } | { kind: "rejected"; reason: MainSessionRecoveryConflict } - | { kind: "recovery_validated" } - | { kind: "reserved"; reservation: MainSessionRecoveryReservation } - | { kind: "tombstoned" }; + | { kind: "reserved"; reservation: MainSessionRecoveryReservation }; diff --git a/src/agents/main-session-restart-dispatch.ts b/src/agents/main-session-restart-dispatch.ts index 35569ca45bb5..a9ef1d008672 100644 --- a/src/agents/main-session-restart-dispatch.ts +++ b/src/agents/main-session-restart-dispatch.ts @@ -13,7 +13,6 @@ import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isTrustedMessageActionTurnIngress } from "../gateway/message-action-turn-capability.js"; import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js"; import { getAgentEventLifecycleGeneration } from "../infra/agent-events.js"; -import { retryAsync } from "../infra/retry.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import { findRestartRecoveryUnsafeReplyHook } from "../plugins/restart-recovery-hook-safety.js"; import { CommandLane } from "../process/lanes.js"; @@ -28,14 +27,14 @@ import { import { isDeliverableMessageChannel } from "../utils/message-channel.js"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "./agent-scope.js"; import { buildMainSessionRecoveryClearPatch } from "./main-session-recovery-clear.js"; +import { + repairMainSessionRecoveryMutation, + retryMainSessionRecoveryMutation, + scheduleMainSessionRecoveryMutation, +} from "./main-session-recovery-lifecycle.js"; import { scheduleMainSessionRecoveryPendingTarget } from "./main-session-recovery-owner-release.js"; import { - restoreAdmittedRecoveryWithRetries, - scheduleAdmittedRecoveryRestore, - type RestoreAdmittedRecovery, -} from "./main-session-recovery-restore.js"; -import { - isMainRestartRecoveryCandidate, + isMainSessionRecoveryPending, type MainSessionRecoveryObservation, type MainSessionRecoveryReservation, } from "./main-session-recovery-state.js"; @@ -43,8 +42,6 @@ import { commitMainSessionRecovery } from "./main-session-recovery-store.js"; import { ensureRuntimePluginsLoaded } from "./runtime-plugins.js"; const log = createSubsystemLogger("main-session-restart-recovery"); -const RESERVATION_ROLLBACK_RETRY_DELAY_MS = 1_000; -const RESERVATION_ROLLBACK_RETRY_MAX_DELAY_MS = 30_000; const RESTART_RECOVERY_RESUME_MESSAGE = "[System] Your previous turn was interrupted by a gateway restart while " + "OpenClaw was waiting on tool/model work. Continue from the existing " + @@ -282,6 +279,56 @@ function isExactRestartRecoveryDispatchAdmission(params: { ); } +async function settleAcceptedRestartRecovery(params: { + expectedRecoveryRunId: string; + expectedRecoverySourceRunId?: string; + expectedSessionId: string; + lifecycleGeneration: string; + reservation?: MainSessionRecoveryReservation; + sessionKey: string; + sessionKeys: readonly string[]; + shouldContinue?: () => boolean; + storePath: string; + terminalStatus?: RestartRecoveryTerminalStatus; +}): Promise { + const admission = await commitMainSessionRecovery({ + command: { + kind: "admit_recovery", + lifecycleGeneration: params.lifecycleGeneration, + now: Date.now(), + runId: params.expectedRecoveryRunId, + sessionId: params.expectedSessionId, + }, + shouldContinue: params.shouldContinue, + target: { sessionKey: params.sessionKey, storePath: params.storePath }, + }); + if ( + admission.transition.kind !== "admitted_recovery" && + !isExactRestartRecoveryDispatchAdmission({ + admission, + lifecycleGeneration: params.lifecycleGeneration, + recoveryRunId: params.expectedRecoveryRunId, + sessionId: params.expectedSessionId, + terminalStatus: params.terminalStatus, + }) + ) { + return false; + } + if (params.shouldContinue?.() === false) { + return true; + } + if (params.reservation) { + await commitMainSessionRecovery({ + command: { kind: "abandon_reservation", reservation: params.reservation }, + target: { sessionKey: params.sessionKey, storePath: params.storePath }, + }); + } + if (params.shouldContinue?.() !== false) { + await settleRestartRecoveryDispatch(params); + } + return true; +} + type MainSessionResumeResult = "resumed" | "skipped" | "failed"; async function rollbackRestartRecoveryReservation(params: { @@ -290,57 +337,41 @@ async function rollbackRestartRecoveryReservation(params: { sessionKey: string; storePath: string; }) { - return await retryAsync( - async () => - await commitMainSessionRecovery({ - command: { kind: params.kind, reservation: params.reservation }, - requireWriteSuccess: true, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }), - 3, - 25, + return await retryMainSessionRecoveryMutation(async () => + commitMainSessionRecovery({ + command: { kind: params.kind, reservation: params.reservation }, + requireWriteSuccess: true, + target: { sessionKey: params.sessionKey, storePath: params.storePath }, + }), ); } function scheduleRestartRecoveryReservationRollback( params: Parameters[0], - delayMs = RESERVATION_ROLLBACK_RETRY_DELAY_MS, ): void { // Keep the exact reservation token alive after transient store outages. // A Gateway restart safely retires the timer and its stale-generation slot. - setTimeout(() => { - void rollbackRestartRecoveryReservation(params).then( - ({ entry, sessionKey }) => { - const state = entry?.mainRestartRecovery; - if ( - entry?.sessionId === params.reservation.sessionId && - sessionKey && - entry.status === "running" && - entry.abortedLastRun === true && - isMainRestartRecoveryCandidate(entry, sessionKey) && - state && - !state.foregroundClaims && - !state.reservation && - !state.tombstone - ) { - scheduleMainSessionRecoveryPendingTarget({ - sessionId: entry.sessionId, - sessionKey, - storePath: params.storePath, - }); - } - }, - (error: unknown) => { - log.warn( - `failed delayed restart recovery reservation rollback ${params.sessionKey}: ${String(error)}`, - ); - scheduleRestartRecoveryReservationRollback( - params, - Math.min(delayMs * 2, RESERVATION_ROLLBACK_RETRY_MAX_DELAY_MS), - ); - }, - ); - }, delayMs).unref?.(); + scheduleMainSessionRecoveryMutation({ + mutation: () => rollbackRestartRecoveryReservation(params), + onError: (error) => { + log.warn( + `failed delayed restart recovery reservation rollback ${params.sessionKey}: ${String(error)}`, + ); + }, + onSuccess: ({ entry, sessionKey }) => { + if ( + entry?.sessionId === params.reservation.sessionId && + sessionKey && + isMainSessionRecoveryPending(entry, sessionKey) + ) { + scheduleMainSessionRecoveryPendingTarget({ + sessionId: entry.sessionId, + sessionKey, + storePath: params.storePath, + }); + } + }, + }); } export async function resumeMainSession(params: { @@ -387,6 +418,20 @@ export async function resumeMainSession(params: { const recoverySessionKeys = Array.from(new Set([dispatchSessionKey, params.sessionKey])); let reservation: MainSessionRecoveryReservation | undefined; let dispatchStarted = false; + const rollbackReservation = async (kind: "abandon_reservation" | "cancel_reservation") => { + if (!reservation) { + return undefined; + } + const current = reservation; + const result = await rollbackRestartRecoveryReservation({ + kind, + reservation: current, + sessionKey: params.sessionKey, + storePath: params.storePath, + }); + reservation = undefined; + return { current, result }; + }; try { const reserved = await commitMainSessionRecovery({ command: { @@ -406,13 +451,7 @@ export async function resumeMainSession(params: { } reservation = reserved.transition.reservation; if (params.shouldContinue?.() === false) { - await rollbackRestartRecoveryReservation({ - kind: "cancel_reservation", - reservation, - sessionKey: params.sessionKey, - storePath: params.storePath, - }); - reservation = undefined; + await rollbackReservation("cancel_reservation"); return "skipped"; } // Persist one stable RPC id before dispatch. A transport rejection is @@ -448,17 +487,11 @@ export async function resumeMainSession(params: { }, }); if (!recoveryStatePrepared) { - const rollback = await rollbackRestartRecoveryReservation({ - kind: "cancel_reservation", - reservation, - sessionKey: params.sessionKey, - storePath: params.storePath, - }); - reservation = undefined; + const rollback = await rollbackReservation("cancel_reservation"); if (params.shouldContinue?.() === false) { return "skipped"; } - const current = rollback.entry; + const current = rollback?.result.entry; return current?.sessionId === params.entry.sessionId && current.status === "running" && current.abortedLastRun === true && @@ -502,13 +535,7 @@ export async function resumeMainSession(params: { } } if (params.shouldContinue?.() === false) { - await rollbackRestartRecoveryReservation({ - kind: "cancel_reservation", - reservation, - sessionKey: params.sessionKey, - storePath: params.storePath, - }); - reservation = undefined; + await rollbackReservation("cancel_reservation"); return "skipped"; } if (params.forceRestartSafeTools) { @@ -537,44 +564,24 @@ export async function resumeMainSession(params: { if (params.shouldContinue?.() === false) { return "skipped"; } - const admission = await commitMainSessionRecovery({ - command: { - kind: "admit_recovery", - lifecycleGeneration, - now: Date.now(), - runId: recoveryRunId, - sessionId: params.entry.sessionId, - }, - shouldContinue: params.shouldContinue, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }); if ( - admission.transition.kind !== "admitted_recovery" && - !isExactRestartRecoveryDispatchAdmission({ - admission, + !(await settleAcceptedRestartRecovery({ + expectedRecoveryRunId: recoveryRunId, + expectedRecoverySourceRunId: sourceRunId, + expectedSessionId: params.entry.sessionId, lifecycleGeneration, - recoveryRunId, - sessionId: params.entry.sessionId, + sessionKey: params.sessionKey, + sessionKeys: recoverySessionKeys, + shouldContinue: params.shouldContinue, + storePath: params.storePath, terminalStatus, - }) + })) ) { throw new Error(`restart recovery admission changed before settlement: ${params.sessionKey}`); } if (params.shouldContinue?.() === false) { return "skipped"; } - await settleRestartRecoveryDispatch({ - expectedRecoveryRunId: recoveryRunId, - expectedRecoverySourceRunId: sourceRunId, - expectedSessionId: params.entry.sessionId, - sessionKeys: recoverySessionKeys, - shouldContinue: params.shouldContinue, - storePath: params.storePath, - terminalStatus, - }); - if (params.shouldContinue?.() === false) { - return "skipped"; - } log.info( `resumed interrupted main session: ${params.sessionKey}${ sanitizedPendingText ? " (with pending payload)" : "" @@ -590,52 +597,23 @@ export async function resumeMainSession(params: { params.gatewayRuntime, ); if (terminalStatus && params.shouldContinue?.() !== false) { - const admission = await commitMainSessionRecovery({ - command: { - kind: "admit_recovery", - lifecycleGeneration, - now: Date.now(), - runId: recoveryRunId, - sessionId: params.entry.sessionId, - }, - shouldContinue: params.shouldContinue, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }); - const exactRunAlreadyAdmitted = isExactRestartRecoveryDispatchAdmission({ - admission, + const settled = await settleAcceptedRestartRecovery({ + expectedRecoveryRunId: recoveryRunId, + expectedRecoverySourceRunId: sourceRunId, + expectedSessionId: params.entry.sessionId, lifecycleGeneration, - recoveryRunId, - sessionId: params.entry.sessionId, + reservation, + sessionKey: params.sessionKey, + sessionKeys: recoverySessionKeys, + shouldContinue: params.shouldContinue, + storePath: params.storePath, terminalStatus, }); - if (admission.transition.kind !== "admitted_recovery" && !exactRunAlreadyAdmitted) { - if (params.shouldContinue?.() !== false) { - log.warn( - `restart recovery admission changed before settlement: ${params.sessionKey}`, - ); - } + if (!settled) { + log.warn(`restart recovery admission changed before settlement: ${params.sessionKey}`); } else if (params.shouldContinue?.() !== false) { - if (reservation) { - await commitMainSessionRecovery({ - command: { kind: "abandon_reservation", reservation }, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }); - } - if (params.shouldContinue?.() !== false) { - await settleRestartRecoveryDispatch({ - expectedRecoveryRunId: recoveryRunId, - expectedRecoverySourceRunId: sourceRunId, - expectedSessionId: params.entry.sessionId, - sessionKeys: recoverySessionKeys, - shouldContinue: params.shouldContinue, - storePath: params.storePath, - terminalStatus, - }); - if (params.shouldContinue?.() !== false) { - log.info(`settled completed restart recovery for ${params.sessionKey}`); - return "resumed"; - } - } + log.info(`settled completed restart recovery for ${params.sessionKey}`); + return "resumed"; } } } @@ -644,7 +622,7 @@ export async function resumeMainSession(params: { log.warn( `failed to settle ambiguous restart recovery ${params.sessionKey}: ${String(settlementError)}`, ); - const restoreAdmittedRecovery: RestoreAdmittedRecovery = async () => { + const restoreAdmittedRecovery = async () => { if (params.shouldContinue?.() === false) { return undefined; } @@ -671,36 +649,32 @@ export async function resumeMainSession(params: { } : undefined; }; - try { - const restored = await restoreAdmittedRecoveryWithRetries(restoreAdmittedRecovery); - if (params.shouldContinue?.() !== false) { - scheduleMainSessionRecoveryPendingTarget(restored); - } - } catch (restoreError) { - if (params.shouldContinue?.() !== false) { - log.warn( - `failed to restore ambiguous restart recovery ${params.sessionKey}: ${String(restoreError)}`, - ); - scheduleAdmittedRecoveryRestore(restoreAdmittedRecovery); - } + const restored = await repairMainSessionRecoveryMutation({ + mutation: restoreAdmittedRecovery, + onDeferredSuccess: scheduleMainSessionRecoveryPendingTarget, + onError: (restoreError) => { + if (params.shouldContinue?.() !== false) { + log.warn( + `failed to restore ambiguous restart recovery ${params.sessionKey}: ${String(restoreError)}`, + ); + } + }, + }); + if (params.shouldContinue?.() !== false) { + scheduleMainSessionRecoveryPendingTarget(restored); } } } if (reservation) { - const rollbackReservation = reservation; - await rollbackRestartRecoveryReservation({ - kind: dispatchStarted && !explicitlyRejected ? "abandon_reservation" : "cancel_reservation", - reservation: rollbackReservation, - sessionKey: params.sessionKey, - storePath: params.storePath, - }).catch((rollbackError: unknown) => { + const rollbackKind = + dispatchStarted && !explicitlyRejected ? "abandon_reservation" : "cancel_reservation"; + await rollbackReservation(rollbackKind).catch((rollbackError: unknown) => { log.warn( `failed to roll back interrupted main session recovery attempt ${params.sessionKey}: ${String(rollbackError)}`, ); scheduleRestartRecoveryReservationRollback({ - kind: - dispatchStarted && !explicitlyRejected ? "abandon_reservation" : "cancel_reservation", - reservation: rollbackReservation, + kind: rollbackKind, + reservation: reservation!, sessionKey: params.sessionKey, storePath: params.storePath, }); diff --git a/src/agents/main-session-restart-recovery-failure.ts b/src/agents/main-session-restart-recovery-failure.ts index 90e56d2fcb6f..95db9104f29b 100644 --- a/src/agents/main-session-restart-recovery-failure.ts +++ b/src/agents/main-session-restart-recovery-failure.ts @@ -1,18 +1,20 @@ import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; import { loadSessionEntry } from "../config/sessions/session-accessor.js"; -import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js"; import { resolveAgentIdFromSessionKey } from "../routing/session-key.js"; -import { - isMainSessionRecoveryExhausted, - type MainSessionRecoveryObservation, -} from "./main-session-recovery-state.js"; +import type { MainSessionRecoveryObservation } from "./main-session-recovery-state.js"; import { commitMainSessionRecovery } from "./main-session-recovery-store.js"; -import { buildUnresumableSessionNoticeIdempotencyKey } from "./main-session-restart-claim.js"; import { resolveRestartRecoveryDeliveryContext } from "./main-session-restart-dispatch.js"; -import { sendUnresumableSessionNotice } from "./main-session-restart-recovery-notice.js"; -import { buildRestartRecoveryExpectedState, log } from "./main-session-restart-recovery-shared.js"; +import { + sendUnresumableSessionNotice, + writeUnresumableSessionNotice, +} from "./main-session-restart-recovery-notice.js"; +import { + buildRestartRecoveryExpectedState, + log, + MAX_RECOVERY_RETRIES, +} from "./main-session-restart-recovery-shared.js"; const TOMBSTONED_SESSION_NOTICE = "I couldn't recover this session after repeated gateway restarts. " + @@ -62,10 +64,33 @@ export async function tombstoneMainRestartRecoveryWithNotice(params: { let entry = params.entry; let observation = params.observation; for (let attempt = 0; attempt < 3; attempt += 1) { + const recoveryState = entry.mainRestartRecovery; + if ( + !recoveryState || + recoveryState.cycleId !== observation.cycleId || + recoveryState.revision !== observation.revision + ) { + return "skipped"; + } + const now = Date.now(); const notice = await writeUnresumableSessionNotice({ - ...params, + agentId: resolveAgentIdFromSessionKey(params.sessionKey), entry, - observation, + expectedSessionState: buildRestartRecoveryExpectedState(entry, observation), + sessionKey: params.sessionKey, + sessionLifecyclePatch: { + abortedLastRun: false, + endedAt: now, + mainRestartRecovery: { + ...recoveryState, + revision: recoveryState.revision + 1, + tombstone: { reason: params.reason }, + }, + runtimeMs: Math.max(0, now - (entry.startedAt ?? now)), + status: "failed", + updatedAt: now, + }, + storePath: params.storePath, text: TOMBSTONED_SESSION_NOTICE, }); if (notice === "written") { @@ -85,7 +110,9 @@ export async function tombstoneMainRestartRecoveryWithNotice(params: { current.sessionId !== params.entry.sessionId || state?.cycleId !== params.observation.cycleId || state.tombstone || - !isMainSessionRecoveryExhausted(current) + current.status !== "running" || + current.abortedLastRun !== true || + state.chargedAttempts < MAX_RECOVERY_RETRIES ) { return "skipped"; } @@ -112,53 +139,3 @@ export async function tombstoneMainRestartRecoveryWithNotice(params: { }); return "tombstoned"; } - -async function writeUnresumableSessionNotice(params: { - entry: SessionEntry; - observation: MainSessionRecoveryObservation; - reason: string; - sessionKey: string; - storePath: string; - text: string; -}): Promise<"failed" | "stale" | "written"> { - const recoveryState = params.entry.mainRestartRecovery; - if ( - !recoveryState || - recoveryState.cycleId !== params.observation.cycleId || - recoveryState.revision !== params.observation.revision - ) { - return "stale"; - } - const now = Date.now(); - const result = await appendAssistantMessageToSessionTranscript({ - agentId: resolveAgentIdFromSessionKey(params.sessionKey), - sessionKey: params.sessionKey, - expectedSessionId: params.entry.sessionId, - expectedSessionState: buildRestartRecoveryExpectedState(params.entry, params.observation), - sessionLifecyclePatch: { - abortedLastRun: false, - endedAt: now, - mainRestartRecovery: { - ...recoveryState, - revision: recoveryState.revision + 1, - tombstone: { reason: params.reason }, - }, - runtimeMs: Math.max(0, now - (params.entry.startedAt ?? now)), - status: "failed", - updatedAt: now, - }, - storePath: params.storePath, - text: params.text, - idempotencyKey: buildUnresumableSessionNoticeIdempotencyKey(params.entry), - }).catch((error: unknown) => ({ ok: false as const, reason: String(error) })); - if (!result.ok) { - log.warn( - `failed to write interrupted main session notice ${params.sessionKey}: ${result.reason}`, - ); - } - return result.ok - ? "written" - : "code" in result && result.code === "session-rebound" - ? "stale" - : "failed"; -} diff --git a/src/agents/main-session-restart-recovery-marking.ts b/src/agents/main-session-restart-recovery-marking.ts index ea5e53eefd9f..50188ee62355 100644 --- a/src/agents/main-session-restart-recovery-marking.ts +++ b/src/agents/main-session-restart-recovery-marking.ts @@ -18,6 +18,7 @@ import { listActiveEmbeddedRunSessionKeys, } from "./embedded-agent-runner/run-state.js"; import { + isMainRestartRecoveryCandidate, normalizeMainSessionRecoveryRunFences, transitionMainSessionRecovery, } from "./main-session-recovery-state.js"; @@ -27,10 +28,50 @@ import { normalizeFiniteTimestamp, normalizeStringSet, resolveRestartRecoveryStorePaths, - shouldSkipMainRecovery, } from "./main-session-restart-recovery-shared.js"; import { resolveAgentSessionDirs } from "./session-dirs.js"; +async function markRecoveryStore(params: { + storePath: string; + statuses?: Array>; + plan: ( + entry: SessionEntry, + sessionKey: string, + ) => { replaceRuns?: boolean; resetRuntime?: boolean; runs?: RestartRecoveryRun[] } | undefined; +}) { + return await applySessionEntryReplacements<{ marked: number; skipped: number }>({ + storePath: params.storePath, + statuses: params.statuses, + requireWriteSuccess: true, + update: (entries) => { + const replacements: Array<{ sessionKey: string; entry: SessionEntry }> = []; + const counts = { marked: 0, skipped: 0 }; + for (const { sessionKey, entry } of entries) { + const plan = params.plan(entry, sessionKey); + if (!plan) { + continue; + } + if (!isMainRestartRecoveryCandidate(entry, sessionKey)) { + counts.skipped++; + continue; + } + if (plan.replaceRuns) { + entry.restartRecoveryRuns = plan.runs; + } + transitionMainSessionRecovery(entry, { + kind: "mark_interrupted", + cycleId: randomUUID(), + now: Date.now(), + ...plan, + }); + replacements.push({ sessionKey, entry }); + counts.marked++; + } + return { result: counts, replacements }; + }, + }); +} + export async function markRestartAbortedMainSessions(params: { cfg?: OpenClawConfig; additionalCfgs?: Iterable; @@ -114,67 +155,49 @@ export async function markRestartAbortedMainSessions(params: { } for (const storePath of storePaths) { - const storeResult = await applySessionEntryReplacements({ + const storeResult = await markRecoveryStore({ storePath, - requireWriteSuccess: true, - update: (entries) => { - const replacements: Array<{ sessionKey: string; entry: SessionEntry }> = []; - const counts = { marked: 0, skipped: 0 }; - for (const { sessionKey, entry } of entries) { - const registeredActiveRuns = listAgentRunsForSession({ - sessionKey, - sessionId: entry.sessionId, - }); - const matchingActiveRuns = activeRuns.filter( - (run) => - (run.sessionId ? run.sessionId === entry.sessionId : run.sessionKey === sessionKey) && - (entry.status === "running" || - run.observedAt === undefined || - normalizeFiniteTimestamp(entry.updatedAt) === undefined || - (entry.updatedAt < run.observedAt && - run.lifecycleGeneration !== currentLifecycleGeneration)) && - params.isActiveRun?.(run) !== false, - ); - if ( - entry.status !== "running" && - matchingActiveRuns.length === 0 && - registeredActiveRuns.length === 0 - ) { - continue; - } - const matches = - typeof entry.sessionId === "string" && sessionIds.has(entry.sessionId) - ? true - : !preferSessionIdMatch && sessionKeys.has(sessionKey); - if (!matches) { - continue; - } - if (shouldSkipMainRecovery(entry, sessionKey)) { - counts.skipped++; - continue; - } - const wasRunning = entry.status === "running"; - entry.restartRecoveryRuns = normalizeMainSessionRecoveryRunFences([ - ...(entry.restartRecoveryRuns ?? []).filter( - (run) => run.lifecycleGeneration === currentLifecycleGeneration, - ), - ...registeredActiveRuns, - ...matchingActiveRuns.map(({ runId, lifecycleGeneration }) => ({ - runId, - lifecycleGeneration, - })), - ]); - transitionMainSessionRecovery(entry, { - kind: "mark_interrupted", - cycleId: randomUUID(), - now: Date.now(), - resetRuntime: !wasRunning, - runs: entry.restartRecoveryRuns, - }); - replacements.push({ sessionKey, entry }); - counts.marked++; + plan: (entry, sessionKey) => { + const registeredActiveRuns = listAgentRunsForSession({ + sessionKey, + sessionId: entry.sessionId, + }); + const matchingActiveRuns = activeRuns.filter( + (run) => + (run.sessionId ? run.sessionId === entry.sessionId : run.sessionKey === sessionKey) && + (entry.status === "running" || + run.observedAt === undefined || + normalizeFiniteTimestamp(entry.updatedAt) === undefined || + (entry.updatedAt < run.observedAt && + run.lifecycleGeneration !== currentLifecycleGeneration)) && + params.isActiveRun?.(run) !== false, + ); + if ( + entry.status !== "running" && + matchingActiveRuns.length === 0 && + registeredActiveRuns.length === 0 + ) { + return undefined; } - return { result: counts, replacements }; + const matches = + typeof entry.sessionId === "string" && sessionIds.has(entry.sessionId) + ? true + : !preferSessionIdMatch && sessionKeys.has(sessionKey); + if (!matches) { + return undefined; + } + const wasRunning = entry.status === "running"; + const runs = normalizeMainSessionRecoveryRunFences([ + ...(entry.restartRecoveryRuns ?? []).filter( + (run) => run.lifecycleGeneration === currentLifecycleGeneration, + ), + ...registeredActiveRuns, + ...matchingActiveRuns.map(({ runId, lifecycleGeneration }) => ({ + runId, + lifecycleGeneration, + })), + ]); + return { replaceRuns: true, resetRuntime: !wasRunning, runs }; }, }); result.marked += storeResult.marked; @@ -215,47 +238,32 @@ export async function markStartupOrphanedMainSessionsForRecovery(params: { providedActiveSessionKeys ?? normalizeStringSet(listActiveEmbeddedRunSessionKeys()); for (const storePath of await resolveRestartRecoveryStorePaths(params)) { - const storeResult = await applySessionEntryReplacements({ + const storeResult = await markRecoveryStore({ storePath, statuses: ["running"], - update: (entries) => { - const replacements: Array<{ sessionKey: string; entry: SessionEntry }> = []; - const counts = { marked: 0, skipped: 0 }; - for (const { sessionKey, entry } of entries) { - if (entry.status !== "running" || entry.abortedLastRun === true) { - continue; - } - if (shouldSkipMainRecovery(entry, sessionKey)) { - counts.skipped++; - continue; - } - const updatedAt = normalizeFiniteTimestamp(entry.updatedAt); - if ( - updatedBeforeMs !== undefined && - updatedAt !== undefined && - updatedAt > updatedBeforeMs - ) { - continue; - } - if ( - hasCurrentProcessOwner({ - activeSessionIds: resolveActiveSessionIds(), - activeSessionKeys: resolveActiveSessionKeys(), - entry, - sessionKey, - }) - ) { - continue; - } - transitionMainSessionRecovery(entry, { - kind: "mark_interrupted", - cycleId: randomUUID(), - now: Date.now(), - }); - replacements.push({ sessionKey, entry }); - counts.marked++; + plan: (entry, sessionKey) => { + if (entry.status !== "running" || entry.abortedLastRun === true) { + return undefined; } - return { result: counts, replacements }; + const updatedAt = normalizeFiniteTimestamp(entry.updatedAt); + if ( + updatedBeforeMs !== undefined && + updatedAt !== undefined && + updatedAt > updatedBeforeMs + ) { + return undefined; + } + if ( + hasCurrentProcessOwner({ + activeSessionIds: resolveActiveSessionIds(), + activeSessionKeys: resolveActiveSessionKeys(), + entry, + sessionKey, + }) + ) { + return undefined; + } + return {}; }, }); result.marked += storeResult.marked; diff --git a/src/agents/main-session-restart-recovery-notice.ts b/src/agents/main-session-restart-recovery-notice.ts index 05a4dc8f4c64..e2f3d537e2c7 100644 --- a/src/agents/main-session-restart-recovery-notice.ts +++ b/src/agents/main-session-restart-recovery-notice.ts @@ -1,5 +1,9 @@ import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import type { InternalSessionEntry as SessionEntry } from "../config/sessions.js"; +import type { + SessionTranscriptTurnExpectedState, + SessionTranscriptTurnLifecyclePatch, +} from "../config/sessions/session-accessor.js"; import { appendAssistantMessageToSessionTranscript } from "../config/sessions/transcript.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { GatewayRecoveryRuntime } from "../gateway/server-instance-runtime.types.js"; @@ -16,28 +20,6 @@ import { UNRESUMABLE_SESSION_NOTICE, } from "./main-session-restart-recovery-shared.js"; -async function markSessionFailed(params: { - observation: MainSessionRecoveryObservation; - storePath: string; - sessionKey: string; - reason: string; -}): Promise { - const marked = await commitMainSessionRecovery({ - command: { - kind: "fail_recovery", - now: Date.now(), - observation: params.observation, - }, - requireWriteSuccess: true, - target: { sessionKey: params.sessionKey, storePath: params.storePath }, - }); - if (marked.transition.kind === "failed") { - log.warn(`marked interrupted main session failed: ${params.sessionKey} (${params.reason})`); - return true; - } - return false; -} - export async function sendUnresumableSessionNotice(params: { deliveryContext: DeliveryContext; entry: SessionEntry; @@ -79,19 +61,24 @@ export async function sendUnresumableSessionNotice(params: { } } -async function writeUnresumableSessionNotice(params: { +export async function writeUnresumableSessionNotice(params: { agentId: string; entry: SessionEntry; sessionKey: string; storePath: string; -}): Promise { + expectedSessionState?: SessionTranscriptTurnExpectedState; + sessionLifecyclePatch?: SessionTranscriptTurnLifecyclePatch; + text?: string; +}): Promise<"failed" | "stale" | "written"> { const result = await appendAssistantMessageToSessionTranscript({ agentId: params.agentId, sessionKey: params.sessionKey, expectedSessionId: params.entry.sessionId, - expectedSessionState: buildRestartRecoveryExpectedState(params.entry), + expectedSessionState: + params.expectedSessionState ?? buildRestartRecoveryExpectedState(params.entry), + sessionLifecyclePatch: params.sessionLifecyclePatch, storePath: params.storePath, - text: UNRESUMABLE_SESSION_NOTICE, + text: params.text ?? UNRESUMABLE_SESSION_NOTICE, idempotencyKey: buildUnresumableSessionNoticeIdempotencyKey(params.entry), }).catch((error: unknown) => ({ ok: false as const, reason: String(error) })); if (!result.ok) { @@ -99,7 +86,11 @@ async function writeUnresumableSessionNotice(params: { `failed to write interrupted main session notice ${params.sessionKey}: ${result.reason}`, ); } - return result.ok; + return result.ok + ? "written" + : "code" in result && result.code === "session-rebound" + ? "stale" + : "failed"; } export async function failUnresumableMainSession(params: { @@ -119,7 +110,7 @@ export async function failUnresumableMainSession(params: { }); if ( !deliveryContext && - !(await writeUnresumableSessionNotice({ + (await writeUnresumableSessionNotice({ agentId: resolveAgentIdFromSessionKey( params.sessionKey, params.cfg ? resolveDefaultAgentId(params.cfg) : undefined, @@ -127,20 +118,20 @@ export async function failUnresumableMainSession(params: { entry: params.entry, sessionKey: params.sessionKey, storePath: params.storePath, - })) + })) !== "written" ) { // Keep ownership for another recovery attempt until its terminal notice is durable. return "failed"; } - const marked = await markSessionFailed({ - observation: params.observation, - storePath: params.storePath, - sessionKey: params.sessionKey, - reason: params.reason, + const marked = await commitMainSessionRecovery({ + command: { kind: "fail_recovery", now: Date.now(), observation: params.observation }, + requireWriteSuccess: true, + target: { sessionKey: params.sessionKey, storePath: params.storePath }, }); - if (!marked) { + if (marked.transition.kind !== "failed") { return "skipped"; } + log.warn(`marked interrupted main session failed: ${params.sessionKey} (${params.reason})`); if (deliveryContext) { await sendUnresumableSessionNotice({ deliveryContext, diff --git a/src/agents/main-session-restart-recovery-runtime.ts b/src/agents/main-session-restart-recovery-runtime.ts index da4e74f13b5e..b319db9d2bf1 100644 --- a/src/agents/main-session-restart-recovery-runtime.ts +++ b/src/agents/main-session-restart-recovery-runtime.ts @@ -4,6 +4,7 @@ import { getAgentEventLifecycleGeneration, isAgentEventLifecycleGenerationCurrent, } from "../infra/agent-events.js"; +import { sleepWithAbort } from "../infra/backoff.js"; import { runWithGatewayIndependentRootWorkAdmission } from "../process/gateway-work-admission.js"; import { beginSessionWorkAdmission, @@ -28,7 +29,44 @@ import { recoverStore, } from "./main-session-restart-recovery-store.js"; -async function recoverRestartAbortedMainSessionsWithOptions(params: { +type RecoveryCounts = { recovered: number; failed: number; skipped: number }; + +async function runRecoveryRetries(params: { + initialDelayMs: number; + maxRetries: number; + retryDelayMs?: number; + shouldContinue: () => boolean; + signal?: AbortSignal; + attempt: (finalAttempt: boolean) => Promise; + onError: (error: unknown, finalAttempt: boolean) => void | Promise; +}): Promise { + let delayMs = params.initialDelayMs; + for (let attempt = 1; attempt <= params.maxRetries && params.shouldContinue(); attempt += 1) { + const finalAttempt = attempt === params.maxRetries; + try { + if (delayMs > 0) { + await sleepWithAbort(delayMs, params.signal, { ref: false }); + } + if (!params.shouldContinue() || (await params.attempt(finalAttempt))) { + return; + } + } catch (error) { + if (!params.shouldContinue()) { + return; + } + await params.onError(error, finalAttempt); + if (finalAttempt) { + return; + } + } + delayMs = + delayMs > 0 + ? delayMs * RETRY_BACKOFF_MULTIPLIER + : (params.retryDelayMs ?? DEFAULT_RECOVERY_DELAY_MS); + } +} + +export async function recoverRestartAbortedMainSessions(params: { cfg?: OpenClawConfig; onExhaustedTarget?: (target: ExhaustedRestartRecoveryTarget) => void; stateDir?: string; @@ -38,7 +76,7 @@ async function recoverRestartAbortedMainSessionsWithOptions(params: { lifecycleGeneration?: string; shouldContinue?: () => boolean; gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { +}): Promise { const result = { recovered: 0, failed: 0, skipped: 0 }; const resumedSessionKeys = params.resumedSessionKeys ?? new Set(); @@ -70,117 +108,75 @@ async function recoverRestartAbortedMainSessionsWithOptions(params: { return result; } -export async function recoverRestartAbortedMainSessions(params: { - cfg?: OpenClawConfig; - stateDir?: string; - resumedSessionKeys?: Set; - activeSessionIds?: Iterable; - activeSessionKeys?: Iterable; - gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { - return await recoverRestartAbortedMainSessionsWithOptions(params); -} - /** Retries one exact durable Control UI row from its owning per-agent SQLite store. */ export async function retryRestartAbortedMainSessionRecovery(params: { canonicalSessionKey?: string; cfg?: OpenClawConfig; - expectedRecoveryRunId: string; - expectedRecoverySourceRunId: string; + expectedRecoveryRunId?: string; + expectedRecoverySourceRunId?: string; expectedSessionId: string; sessionKey: string; storePath: string; gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { - const expectedClaim: ExpectedRestartRecoveryClaim = { +}): Promise { + const expected = { canonicalSessionKey: params.canonicalSessionKey, - recoveryRunId: params.expectedRecoveryRunId, - recoverySourceRunId: params.expectedRecoverySourceRunId, sessionId: params.expectedSessionId, sessionKey: params.sessionKey, }; - if (!loadExpectedRestartRecoveryClaim({ expected: expectedClaim, storePath: params.storePath })) { - return { recovered: 0, failed: 0, skipped: 0 }; - } - const assertClaimCurrent = () => { - if ( - !loadExpectedRestartRecoveryClaim({ expected: expectedClaim, storePath: params.storePath }) - ) { - throw new Error("restart recovery session ownership changed before dispatch"); - } - }; - // Keep lifecycle replacement behind the accepted recovery dispatch. The agent - // RPC atomically adopts this lease, so no second admission can deadlock behind - // a mutation that already sees the accepted browser turn as active work. - const admission = await beginSessionWorkAdmission({ - scope: params.storePath, - identities: [params.sessionKey, params.canonicalSessionKey, params.expectedSessionId], - assertAllowed: assertClaimCurrent, - revalidateAllowed: assertClaimCurrent, + const expectedClaim: ExpectedRestartRecoveryClaim | undefined = + params.expectedRecoveryRunId && params.expectedRecoverySourceRunId + ? { + ...expected, + recoveryRunId: params.expectedRecoveryRunId, + recoverySourceRunId: params.expectedRecoverySourceRunId, + } + : undefined; + return await recoverExpectedRestartRecovery({ + ...params, + ...(expectedClaim ? { expectedClaim } : { expectedTarget: expected }), }); - const handoffId = admission.createHandoff(); - try { - return await admission.run( - async () => - await recoverStore({ - cfg: params.cfg, - storePath: params.storePath, - resumedSessionKeys: new Set(), - expectedClaim, - sessionWorkAdmissionHandoffId: handoffId, - gatewayRuntime: params.gatewayRuntime, - }), - ); - } finally { - cancelSessionWorkAdmissionHandoff(handoffId); - admission.release(); - } } -/** Reconciles one interrupted row after its final foreground owner releases. */ -export async function retryRestartAbortedMainSessionRecoveryAfterOwnerRelease(params: { +async function recoverExpectedRestartRecovery(params: { cfg?: OpenClawConfig; - expectedSessionId: string; - sessionKey: string; - storePath: string; - gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { - return await recoverExpectedRestartRecoveryTarget(params); -} - -async function recoverExpectedRestartRecoveryTarget(params: { - canonicalSessionKey?: string; - cfg?: OpenClawConfig; - expectedSessionId: string; + expectedClaim?: ExpectedRestartRecoveryClaim; + expectedTarget?: ExpectedRestartRecoveryTarget; lifecycleGeneration?: string; observationOnly?: boolean; sessionKey: string; shouldContinue?: () => boolean; storePath: string; gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ recovered: number; failed: number; skipped: number }> { - const expectedTarget: ExpectedRestartRecoveryTarget = { - canonicalSessionKey: params.canonicalSessionKey, - sessionId: params.expectedSessionId, - sessionKey: params.sessionKey, - }; - const assertTargetCurrent = () => { - if ( - !loadExpectedRestartRecoveryTarget({ expected: expectedTarget, storePath: params.storePath }) - ) { - throw new Error("restart recovery session ownership changed before owner-release retry"); - } - }; - if ( - !loadExpectedRestartRecoveryTarget({ expected: expectedTarget, storePath: params.storePath }) - ) { +}): Promise { + const loadExpected = () => + params.expectedClaim + ? loadExpectedRestartRecoveryClaim({ + expected: params.expectedClaim, + storePath: params.storePath, + }) + : params.expectedTarget + ? loadExpectedRestartRecoveryTarget({ + expected: params.expectedTarget, + storePath: params.storePath, + }) + : undefined; + if (!loadExpected()) { return { recovered: 0, failed: 0, skipped: 0 }; } + const assertExpectedCurrent = () => { + if (!loadExpected()) { + throw new Error("restart recovery session ownership changed before dispatch"); + } + }; + const expectedSessionId = (params.expectedClaim ?? params.expectedTarget)!.sessionId; + // Keep lifecycle replacement behind accepted recovery dispatch. The RPC + // adopts this lease, so another admission cannot deadlock behind its active work. const admission = await beginSessionWorkAdmission({ scope: params.storePath, - identities: [params.sessionKey, params.expectedSessionId], - assertAllowed: assertTargetCurrent, - revalidateAllowed: assertTargetCurrent, + identities: [params.sessionKey, params.expectedClaim?.canonicalSessionKey, expectedSessionId], + assertAllowed: assertExpectedCurrent, + revalidateAllowed: assertExpectedCurrent, }); const handoffId = admission.createHandoff(); try { @@ -191,7 +187,8 @@ async function recoverExpectedRestartRecoveryTarget(params: { observationOnly: params.observationOnly, storePath: params.storePath, resumedSessionKeys: new Set(), - expectedTarget, + expectedClaim: params.expectedClaim, + expectedTarget: params.expectedTarget, sessionWorkAdmissionHandoffId: handoffId, lifecycleGeneration: params.lifecycleGeneration, shouldContinue: params.shouldContinue, @@ -206,125 +203,61 @@ async function recoverExpectedRestartRecoveryTarget(params: { export function scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease(params: { delayMs?: number; - expectedSessionId: string; getConfig: () => OpenClawConfig; getGatewayRuntime: () => GatewayRecoveryRuntime | undefined; maxRetries?: number; + expectedSessionId: string; sessionKey: string; storePath: string; }): void { - const retryDelayMs = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; - const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES; - const scheduleAttempt = (attempt: number, delayMs: number) => { - const run = () => { - void runWithGatewayIndependentRootWorkAdmission(async () => { - const gatewayRuntime = params.getGatewayRuntime(); - if (!gatewayRuntime) { - throw new Error("Gateway recovery runtime is unavailable"); - } - return await retryRestartAbortedMainSessionRecoveryAfterOwnerRelease({ - cfg: params.getConfig(), - expectedSessionId: params.expectedSessionId, + const recover = () => + runWithGatewayIndependentRootWorkAdmission(async () => { + const gatewayRuntime = params.getGatewayRuntime(); + if (!gatewayRuntime) { + throw new Error("Gateway recovery runtime is unavailable"); + } + return await retryRestartAbortedMainSessionRecovery({ + cfg: params.getConfig(), + expectedSessionId: params.expectedSessionId, + sessionKey: params.sessionKey, + storePath: params.storePath, + gatewayRuntime, + }); + }); + void runRecoveryRetries({ + initialDelayMs: 0, + maxRetries: params.maxRetries ?? MAX_RECOVERY_RETRIES, + retryDelayMs: params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS, + shouldContinue: () => true, + attempt: async (finalAttempt) => { + const result = await recover(); + const stillPending = loadExpectedRestartRecoveryTarget({ + expected: { + sessionId: params.expectedSessionId, sessionKey: params.sessionKey, - storePath: params.storePath, - gatewayRuntime, - }); - }) - .then((result) => { - const stillPending = loadExpectedRestartRecoveryTarget({ - expected: { - sessionId: params.expectedSessionId, - sessionKey: params.sessionKey, - }, - storePath: params.storePath, - }); - if ( - (result.failed > 0 || (result.recovered === 0 && stillPending)) && - attempt < maxRetries - ) { - scheduleAttempt(attempt + 1, retryDelayMs * 2 ** (attempt - 1)); - } else if ( - attempt === maxRetries && - stillPending?.mainRestartRecovery?.chargedAttempts === MAX_RECOVERY_RETRIES && - !stillPending.mainRestartRecovery.reservation - ) { - // The last ambiguous dispatch consumed the final durable charge. - // One exact observation tombstones exhaustion without dispatching again. - scheduleAttempt(attempt + 1, 0); - } - }) - .catch((error: unknown) => { - if (attempt < maxRetries) { - scheduleAttempt(attempt + 1, retryDelayMs * 2 ** (attempt - 1)); - } else { - log.warn(`main-session owner-release recovery failed: ${String(error)}`); - } - }); - }; - if (delayMs <= 0) { - run(); - } else { - setTimeout(run, delayMs).unref?.(); - } - }; - scheduleAttempt(1, 0); -} - -async function recoverStartupOrphanedMainSessionsWithOptions(params: { - cfg?: OpenClawConfig; - stateDir?: string; - activeSessionIds?: Iterable; - activeSessionKeys?: Iterable; - updatedBeforeMs?: number; - resumedSessionKeys?: Set; - onExhaustedTarget?: (target: ExhaustedRestartRecoveryTarget) => void; - lifecycleGeneration?: string; - shouldContinue?: () => boolean; - gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ marked: number; recovered: number; failed: number; skipped: number }> { - if (params.shouldContinue?.() === false) { - return { marked: 0, recovered: 0, failed: 0, skipped: 0 }; - } - const startupRecoveryCutoffMs = params.updatedBeforeMs ?? Date.now(); - const marked = await markStartupOrphanedMainSessionsForRecovery({ - cfg: params.cfg, - stateDir: params.stateDir, - activeSessionIds: params.activeSessionIds, - activeSessionKeys: params.activeSessionKeys, - updatedBeforeMs: startupRecoveryCutoffMs, + }, + storePath: params.storePath, + }); + if (result.failed === 0 && (result.recovered > 0 || !stillPending)) { + return true; + } + if ( + finalAttempt && + stillPending?.mainRestartRecovery?.chargedAttempts === MAX_RECOVERY_RETRIES && + !stillPending.mainRestartRecovery.reservation + ) { + // The last ambiguous dispatch consumed the final durable charge. One + // exact observation tombstones exhaustion without dispatching again. + await recover(); + } + return false; + }, + onError: (error, finalAttempt) => { + if (finalAttempt) { + log.warn(`main-session owner-release recovery failed: ${String(error)}`); + } + }, }); - if (params.shouldContinue?.() === false) { - return { marked: marked.marked, recovered: 0, failed: 0, skipped: marked.skipped }; - } - const recovered = await recoverRestartAbortedMainSessionsWithOptions({ - cfg: params.cfg, - onExhaustedTarget: params.onExhaustedTarget, - stateDir: params.stateDir, - resumedSessionKeys: params.resumedSessionKeys, - activeSessionIds: params.activeSessionIds, - activeSessionKeys: params.activeSessionKeys, - lifecycleGeneration: params.lifecycleGeneration, - shouldContinue: params.shouldContinue, - gatewayRuntime: params.gatewayRuntime, - }); - return { - marked: marked.marked, - recovered: recovered.recovered, - failed: recovered.failed, - skipped: marked.skipped + recovered.skipped, - }; -} - -export async function recoverStartupOrphanedMainSessions(params: { - cfg?: OpenClawConfig; - stateDir?: string; - activeSessionIds?: Iterable; - activeSessionKeys?: Iterable; - updatedBeforeMs?: number; - resumedSessionKeys?: Set; - gatewayRuntime: GatewayRecoveryRuntime; -}): Promise<{ marked: number; recovered: number; failed: number; skipped: number }> { - return await recoverStartupOrphanedMainSessionsWithOptions(params); } export function scheduleRestartAbortedMainSessionRecovery(params: { @@ -336,158 +269,109 @@ export function scheduleRestartAbortedMainSessionRecovery(params: { waitForStart?: () => Promise; gatewayRuntime: GatewayRecoveryRuntime; }): { stop: () => Promise } { - const initialDelay = params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS; - const maxRetries = params.maxRetries ?? MAX_RECOVERY_RETRIES; const resumedSessionKeys = new Set(); const lifecycleGeneration = getAgentEventLifecycleGeneration(); + const abortController = new AbortController(); let stopped = false; - let timer: ReturnType | undefined; - let queuedAttempt: Promise | undefined; - let activeAttempt: Promise | undefined; - let cancelStartWait: (() => void) | undefined; - const startWaitCancelled = new Promise((resolve) => { - cancelStartWait = resolve; - }); const shouldContinue = () => !stopped && params.shouldContinue?.() !== false && isAgentEventLifecycleGenerationCurrent(lifecycleGeneration); - // Capture the cutoff at registration, before any startup gate can release new - // work. Sessions created by this gateway must never become recovery candidates. const startupRecoveryCutoffMs = Date.now(); - - const runRecoveryAttempt = (attempt: number, delay: number) => { - if (!shouldContinue()) { - return; - } - const exhaustedTargets = new Map(); - const reconcileExhaustedTargets = async () => { - if (!shouldContinue()) { - return; - } - const outcomes = await Promise.allSettled( - [...exhaustedTargets.values()].map((target) => - runWithGatewayIndependentRootWorkAdmission( - async () => - await recoverExpectedRestartRecoveryTarget({ - canonicalSessionKey: target.canonicalSessionKey, - cfg: params.cfg, - expectedSessionId: target.sessionId, - lifecycleGeneration, - observationOnly: true, - sessionKey: target.sessionKey, - shouldContinue, - storePath: target.storePath, - gatewayRuntime: params.gatewayRuntime, - }), - ), - ), - ); - for (const outcome of outcomes) { - if (outcome.status === "rejected") { - log.warn(`main-session exhaustion reconciliation failed: ${String(outcome.reason)}`); - } - } - }; - // Delayed retries outlive startup; each attempt must independently block - // host suspension while it reads and rewrites recovery session state. - const pendingAttempt = runWithGatewayIndependentRootWorkAdmission( - async () => - await recoverStartupOrphanedMainSessionsWithOptions({ + let startupMarked = false; + const runRecoveryAttempt = async ( + exhaustedTargets: Map, + ): Promise => { + return await runWithGatewayIndependentRootWorkAdmission(async () => { + if (!startupMarked) { + await markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg, - onExhaustedTarget: (target) => { - exhaustedTargets.set(`${target.storePath}\u0000${target.sessionKey}`, target); - }, stateDir: params.stateDir, - resumedSessionKeys, updatedBeforeMs: startupRecoveryCutoffMs, - lifecycleGeneration, - shouldContinue, - gatewayRuntime: params.gatewayRuntime, - }), - ) - .then(async (result) => { - if (!shouldContinue()) { - return; - } - if (result.failed > 0 && attempt < maxRetries) { - const retryDelay = - delay > 0 ? delay * RETRY_BACKOFF_MULTIPLIER : DEFAULT_RECOVERY_DELAY_MS; - scheduleAttempt(attempt + 1, retryDelay); - } else if (result.failed > 0 && attempt === maxRetries && exhaustedTargets.size > 0) { - // Reconcile only exact rows whose final dispatch retained its durable charge. - await reconcileExhaustedTargets(); - } - }) - .catch(async (err: unknown) => { - if (!shouldContinue()) { - return; - } - if (attempt < maxRetries) { - log.warn(`main-session restart recovery failed: ${String(err)}`); - const retryDelay = - delay > 0 ? delay * RETRY_BACKOFF_MULTIPLIER : DEFAULT_RECOVERY_DELAY_MS; - scheduleAttempt(attempt + 1, retryDelay); - } else { - log.warn(`main-session restart recovery gave up: ${String(err)}`); - await reconcileExhaustedTargets(); - } + }); + startupMarked = true; + } + return await recoverRestartAbortedMainSessions({ + cfg: params.cfg, + onExhaustedTarget: (target) => { + exhaustedTargets.set(`${target.storePath}\u0000${target.sessionKey}`, target); + }, + stateDir: params.stateDir, + resumedSessionKeys, + lifecycleGeneration, + shouldContinue, + gatewayRuntime: params.gatewayRuntime, }); - const trackedAttempt = pendingAttempt.finally(() => { - if (activeAttempt === trackedAttempt) { - activeAttempt = undefined; - } }); - activeAttempt = trackedAttempt; }; - - const queueRecoveryAttempt = (attempt: number, delay: number) => { - const pendingStart = Promise.resolve().then(async () => { - if (attempt === 1 && params.waitForStart) { - // Shutdown must cancel an unresolved startup gate so failed startup and - // same-port replacement cannot hang while joining this lifetime owner. - await Promise.race([params.waitForStart(), startWaitCancelled]); + const reconcileExhaustedTargets = async (targets: Iterable) => { + const outcomes = await Promise.allSettled( + [...targets].map((target) => + runWithGatewayIndependentRootWorkAdmission(async () => + recoverExpectedRestartRecovery({ + cfg: params.cfg, + expectedTarget: { + canonicalSessionKey: target.canonicalSessionKey, + sessionId: target.sessionId, + sessionKey: target.sessionKey, + }, + lifecycleGeneration, + observationOnly: true, + sessionKey: target.sessionKey, + shouldContinue, + storePath: target.storePath, + gatewayRuntime: params.gatewayRuntime, + }), + ), + ), + ); + for (const outcome of outcomes) { + if (outcome.status === "rejected") { + log.warn(`main-session exhaustion reconciliation failed: ${String(outcome.reason)}`); } - if (shouldContinue()) { - runRecoveryAttempt(attempt, delay); - } - }); - const trackedStart = pendingStart.finally(() => { - if (queuedAttempt === trackedStart) { - queuedAttempt = undefined; - } - }); - queuedAttempt = trackedStart; - }; - - const scheduleAttempt = (attempt: number, delay: number) => { - if (!shouldContinue()) { - return; } - if (delay <= 0) { - queueRecoveryAttempt(attempt, delay); - return; - } - timer = setTimeout(() => { - timer = undefined; - queueRecoveryAttempt(attempt, delay); - }, delay); - timer.unref?.(); }; - - scheduleAttempt(1, initialDelay); + const cancelled = new Promise((resolve) => { + abortController.signal.addEventListener("abort", () => resolve(), { once: true }); + }); + let exhaustedTargets = new Map(); + const run = Promise.resolve().then(async () => { + if (params.waitForStart) { + await Promise.race([params.waitForStart(), cancelled]); + } + await runRecoveryRetries({ + initialDelayMs: params.delayMs ?? DEFAULT_RECOVERY_DELAY_MS, + maxRetries: Math.max(1, params.maxRetries ?? MAX_RECOVERY_RETRIES), + shouldContinue, + signal: abortController.signal, + attempt: async (finalAttempt) => { + exhaustedTargets = new Map(); + const result = await runRecoveryAttempt(exhaustedTargets); + if (result.failed === 0) { + return true; + } + if (finalAttempt && exhaustedTargets.size > 0) { + await reconcileExhaustedTargets(exhaustedTargets.values()); + } + return false; + }, + onError: async (err, finalAttempt) => { + if (finalAttempt) { + log.warn(`main-session restart recovery gave up: ${String(err)}`); + await reconcileExhaustedTargets(exhaustedTargets.values()); + } else { + log.warn(`main-session restart recovery failed: ${String(err)}`); + } + }, + }); + }); return { stop: async () => { // Restart recovery belongs to its startup generation; stale timers must // never claim a session after that gateway begins draining. stopped = true; - cancelStartWait?.(); - if (timer) { - clearTimeout(timer); - timer = undefined; - } - await queuedAttempt; - await activeAttempt; + abortController.abort(); + await run; }, }; } diff --git a/src/agents/main-session-restart-recovery-shared.ts b/src/agents/main-session-restart-recovery-shared.ts index 6f6cb0d7376a..dbc97333160a 100644 --- a/src/agents/main-session-restart-recovery-shared.ts +++ b/src/agents/main-session-restart-recovery-shared.ts @@ -4,10 +4,12 @@ import { type InternalSessionEntry as SessionEntry, resolveAllAgentSessionStoreTargetsSync, } from "../config/sessions.js"; -import type { SessionTranscriptTurnExpectedState } from "../config/sessions/session-accessor.js"; +import { + hasSessionEntriesByStatusReadOnly, + type SessionTranscriptTurnExpectedState, +} from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; -import { isMainRestartRecoveryCandidate } from "./main-session-recovery-state.js"; import { resolveAgentSessionDirs } from "./session-dirs.js"; export const log = createSubsystemLogger("main-session-restart-recovery"); @@ -53,10 +55,6 @@ export function buildRestartRecoveryExpectedState( }; } -export function shouldSkipMainRecovery(entry: SessionEntry, sessionKey: string): boolean { - return !isMainRestartRecoveryCandidate(entry, sessionKey); -} - export function normalizeStringSet(values: Iterable | undefined): Set { const normalized = new Set(); for (const value of values ?? []) { @@ -90,14 +88,18 @@ export async function resolveRestartRecoveryStorePaths(params: { }): Promise { const storePaths = new Set(); const stateDir = params.stateDir ?? resolveStateDir(process.env); + const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; for (const sessionsDir of await resolveAgentSessionDirs(stateDir)) { storePaths.add(path.join(sessionsDir, "sessions.json")); } if (params.cfg) { - const env = { ...process.env, OPENCLAW_STATE_DIR: stateDir }; for (const target of resolveAllAgentSessionStoreTargetsSync(params.cfg, { env })) { storePaths.add(path.resolve(target.storePath)); } } - return [...storePaths].toSorted((a, b) => a.localeCompare(b)); + // Agent databases also hold auth and model-catalog state. Enter the writer + // lane only when the session owner proves that a running row may need repair. + return [...storePaths] + .filter((storePath) => hasSessionEntriesByStatusReadOnly({ env, storePath }, ["running"])) + .toSorted((a, b) => a.localeCompare(b)); } diff --git a/src/agents/main-session-restart-recovery-store.ts b/src/agents/main-session-restart-recovery-store.ts index 45ab917e7746..c524591c5c6b 100644 --- a/src/agents/main-session-restart-recovery-store.ts +++ b/src/agents/main-session-restart-recovery-store.ts @@ -51,7 +51,6 @@ import { log, MAX_RECOVERY_RETRIES, normalizeStringSet, - shouldSkipMainRecovery, } from "./main-session-restart-recovery-shared.js"; export function loadExpectedRestartRecoveryTarget(params: { @@ -112,6 +111,13 @@ export async function recoverStore(params: { }): Promise<{ recovered: number; failed: number; skipped: number }> { const result = { recovered: 0, failed: 0, skipped: 0 }; const shouldContinue = () => params.shouldContinue?.() !== false; + const stopped = () => { + if (shouldContinue()) { + return false; + } + result.skipped++; + return true; + }; const resumeIfCurrent = async (resumeParams: Parameters[0]) => { if (!shouldContinue()) { return "skipped" as const; @@ -158,8 +164,7 @@ export async function recoverStore(params: { for (const { sessionKey, entry: loadedEntry } of entries.toSorted((a, b) => a.sessionKey.localeCompare(b.sessionKey), )) { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } let entry = loadedEntry; @@ -170,7 +175,7 @@ export async function recoverStore(params: { if (!entry || entry.status !== "running" || entry.abortedLastRun !== true) { continue; } - if (shouldSkipMainRecovery(entry, sessionKey)) { + if (!isMainRestartRecoveryCandidate(entry, sessionKey)) { result.skipped++; continue; } @@ -208,8 +213,7 @@ export async function recoverStore(params: { continue; } - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } const observed = await commitMainSessionRecovery({ @@ -227,8 +231,7 @@ export async function recoverStore(params: { result.skipped++; continue; } - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } entry = observed.entry; @@ -242,8 +245,7 @@ export async function recoverStore(params: { continue; } if (recoveryView.status === "exhausted") { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } const tombstone = await tombstoneMainRestartRecoveryWithNotice({ @@ -291,54 +293,16 @@ export async function recoverStore(params: { } } }; - - if ( - requiresRestartRecoveryMessageActionAuthority(entry) && - !hasRestartRecoveryMessageActionAuthority(entry) - ) { - if (!shouldContinue()) { - result.skipped++; - return result; - } - const disposition = await failUnresumableMainSession({ - cfg: params.cfg, - entry, - gatewayRuntime: params.gatewayRuntime, - observation: recoveryView.observation, - reason: "message-tool-only recovery authority is unavailable", - sessionKey, - storePath: params.storePath, - }); - result[disposition]++; - continue; - } - - const expectedRecoverySourceRunId = normalizeOptionalString( - entry.restartRecoveryDeliverySourceRunId, - ); - let resumeBlockReason: string | undefined; - let resumeSafetyResolved = false; - const failBlockedResume = async (): Promise => { - if (!resumeSafetyResolved) { - resumeSafetyResolved = true; - resumeBlockReason = resolveRestartRecoveryResumeBlockReason({ - cfg: params.cfg, - entry, - sessionKey, - }); - } - if (!resumeBlockReason) { + const failCurrent = async (reason: string) => { + if (stopped()) { return false; } - if (!shouldContinue()) { - return true; - } const disposition = await failUnresumableMainSession({ cfg: params.cfg, entry, gatewayRuntime: params.gatewayRuntime, observation: recoveryView.observation, - reason: resumeBlockReason, + reason, sessionKey, storePath: params.storePath, }); @@ -346,27 +310,67 @@ export async function recoverStore(params: { return true; }; + if ( + requiresRestartRecoveryMessageActionAuthority(entry) && + !hasRestartRecoveryMessageActionAuthority(entry) + ) { + if (!(await failCurrent("message-tool-only recovery authority is unavailable"))) { + return result; + } + continue; + } + + const expectedRecoverySourceRunId = normalizeOptionalString( + entry.restartRecoveryDeliverySourceRunId, + ); + const failBlockedResume = async (): Promise => { + const resumeBlockReason = resolveRestartRecoveryResumeBlockReason({ + cfg: params.cfg, + entry, + sessionKey, + }); + if (!resumeBlockReason) { + return false; + } + if (!shouldContinue()) { + return true; + } + await failCurrent(resumeBlockReason); + return true; + }; + const resumeCurrent = async ( + options: Pick< + Parameters[0], + "forceCodeModeTools" | "forceRestartSafeTools" | "pendingFinalDeliveryText" + > = {}, + ) => { + if (await failBlockedResume()) { + return; + } + recordResumeResult( + await resumeIfCurrent({ + canonicalSessionKey: dispatchSessionKey, + cfg: params.cfg, + entry, + observation: recoveryView.observation, + recoveryAttempt: recoveryView.nextAttempt, + storePath: params.storePath, + sessionKey, + sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, + gatewayRuntime: params.gatewayRuntime, + ...options, + }), + ); + }; + if ( entry.pendingFinalDelivery?.kind === "replayable" && entry.restartRecoveryForceSafeTools === true ) { - if (await failBlockedResume()) { - continue; - } - const resumed = await resumeIfCurrent({ - canonicalSessionKey: dispatchSessionKey, - cfg: params.cfg, - entry, - observation: recoveryView.observation, - recoveryAttempt: recoveryView.nextAttempt, - storePath: params.storePath, - sessionKey, + await resumeCurrent({ pendingFinalDeliveryText: entry.pendingFinalDelivery.text, forceRestartSafeTools: true, - sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, - gatewayRuntime: params.gatewayRuntime, }); - recordResumeResult(resumed); continue; } @@ -387,30 +391,16 @@ export async function recoverStore(params: { }, ); } catch (err) { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } if (entry.pendingFinalDelivery?.kind === "replayable") { - if (await failBlockedResume()) { - continue; - } log.warn( `transcript unavailable for ${sessionKey}; resuming its durable pending final delivery`, ); - const resumed = await resumeIfCurrent({ - canonicalSessionKey: dispatchSessionKey, - cfg: params.cfg, - entry, - observation: recoveryView.observation, - recoveryAttempt: recoveryView.nextAttempt, - storePath: params.storePath, - sessionKey, + await resumeCurrent({ pendingFinalDeliveryText: entry.pendingFinalDelivery.text, - sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, - gatewayRuntime: params.gatewayRuntime, }); - recordResumeResult(resumed); continue; } log.warn(`failed to read transcript for ${sessionKey}: ${String(err)}`); @@ -418,28 +408,14 @@ export async function recoverStore(params: { continue; } - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } if (entry.pendingFinalDelivery?.kind === "replayable") { - if (await failBlockedResume()) { - continue; - } - const resumed = await resumeIfCurrent({ - canonicalSessionKey: dispatchSessionKey, - cfg: params.cfg, - entry, - observation: recoveryView.observation, - recoveryAttempt: recoveryView.nextAttempt, - storePath: params.storePath, - sessionKey, + await resumeCurrent({ pendingFinalDeliveryText: entry.pendingFinalDelivery.text, forceRestartSafeTools: hasReplaySafeCodeModeCheckpointInCurrentTurn(messages), - sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, - gatewayRuntime: params.gatewayRuntime, }); - recordResumeResult(resumed); continue; } @@ -453,8 +429,7 @@ export async function recoverStore(params: { ? "transcript" : undefined; if (completionSource) { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } const reconciliation = await reconcileInterruptedCompletionReport({ @@ -486,8 +461,7 @@ export async function recoverStore(params: { entry.restartRecoveryDeliveryToolCallId, ); if (resumePolicy.action === "complete") { - if (!shouldContinue()) { - result.skipped++; + if (stopped()) { return result; } const completion = await markSessionCompletedAfterRecoveryCheckpoint({ @@ -510,59 +484,24 @@ export async function recoverStore(params: { } else if (completion.outcome === "changed") { result.skipped++; } else { - if (!shouldContinue()) { - result.skipped++; + if (!(await failCurrent(completion.reason))) { return result; } - const disposition = await failUnresumableMainSession({ - cfg: params.cfg, - entry, - gatewayRuntime: params.gatewayRuntime, - observation: recoveryView.observation, - reason: completion.reason, - sessionKey, - storePath: params.storePath, - }); - result[disposition]++; } continue; } if (resumePolicy.action === "fail") { - if (!shouldContinue()) { - result.skipped++; + if (!(await failCurrent(resumePolicy.reason))) { return result; } - const disposition = await failUnresumableMainSession({ - cfg: params.cfg, - entry, - gatewayRuntime: params.gatewayRuntime, - observation: recoveryView.observation, - reason: resumePolicy.reason, - sessionKey, - storePath: params.storePath, - }); - result[disposition]++; continue; } - if (await failBlockedResume()) { - continue; - } - const resumed = await resumeIfCurrent({ - canonicalSessionKey: dispatchSessionKey, - cfg: params.cfg, - entry, - observation: recoveryView.observation, - recoveryAttempt: recoveryView.nextAttempt, - storePath: params.storePath, - sessionKey, + await resumeCurrent({ forceRestartSafeTools: entry.restartRecoveryForceSafeTools === true || resumePolicy.forceRestartSafeTools, forceCodeModeTools: resumePolicy.forceCodeModeTools === true, - sessionWorkAdmissionHandoffId: params.sessionWorkAdmissionHandoffId, - gatewayRuntime: params.gatewayRuntime, }); - recordResumeResult(resumed); } return result; diff --git a/src/agents/main-session-restart-recovery.test.ts b/src/agents/main-session-restart-recovery.test.ts index dd307958cf1b..b4059581146c 100644 --- a/src/agents/main-session-restart-recovery.test.ts +++ b/src/agents/main-session-restart-recovery.test.ts @@ -5,7 +5,6 @@ import path from "node:path"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { GatewayClientRequestError } from "../../packages/gateway-client/src/index.js"; import { markInboundContextLabel } from "../auto-reply/reply/inbound-context-marker.js"; -import { createReplyOperation } from "../auto-reply/reply/reply-run-registry.js"; import type { ChannelOutboundAdapter } from "../channels/plugins/types.public.js"; import type { CliDeps } from "../cli/outbound-send-deps.js"; import type { OpenClawConfig } from "../config/config.js"; @@ -44,6 +43,10 @@ import { isSessionWorkAdmissionActive, runExclusiveSessionLifecycleMutation, } from "../sessions/session-lifecycle-admission.js"; +import { + closeOpenClawAgentDatabasesForTest, + openOpenClawAgentDatabase, +} from "../state/openclaw-agent-db.js"; import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import { createOutboundTestPlugin, createTestRegistry } from "../test-utils/channel-plugins.js"; import { createDeferred } from "../test-utils/deferred.js"; @@ -52,8 +55,6 @@ import { deliverAgentCommandResult } from "./command/delivery.js"; import { setActiveEmbeddedRunLifecycleGeneration } from "./embedded-agent-runner/run-state.js"; import { clearActiveEmbeddedRun, - queueEmbeddedAgentMessageWithOutcomeAsync, - resolveActiveEmbeddedRunHandleSessionId, setActiveEmbeddedRun, type EmbeddedAgentQueueHandle, } from "./embedded-agent-runner/runs.js"; @@ -66,10 +67,8 @@ import { claimMainSessionRecoveryOwner } from "./main-session-recovery-store.js" import { markRestartAbortedMainSessions, markStartupOrphanedMainSessionsForRecovery, - recoverStartupOrphanedMainSessions as recoverStartupOrphanedMainSessionsBase, recoverRestartAbortedMainSessions as recoverRestartAbortedMainSessionsBase, retryRestartAbortedMainSessionRecovery as retryRestartAbortedMainSessionRecoveryBase, - retryRestartAbortedMainSessionRecoveryAfterOwnerRelease as retryRestartAbortedMainSessionRecoveryAfterOwnerReleaseBase, scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease, scheduleRestartAbortedMainSessionRecovery as scheduleRestartAbortedMainSessionRecoveryBase, } from "./main-session-restart-recovery.js"; @@ -115,21 +114,11 @@ type RecoveryParams = Omit[0]>, ) => recoverRestartAbortedMainSessionsBase({ gatewayRuntime: mockRecoveryRuntime, ...params }); -const recoverStartupOrphanedMainSessions = ( - params: RecoveryParams[0]>, -) => recoverStartupOrphanedMainSessionsBase({ gatewayRuntime: mockRecoveryRuntime, ...params }); const retryRestartAbortedMainSessionRecovery = ( params: RecoveryParams[0]>, ) => retryRestartAbortedMainSessionRecoveryBase({ gatewayRuntime: mockRecoveryRuntime, ...params }); -const retryRestartAbortedMainSessionRecoveryAfterOwnerRelease = ( - params: RecoveryParams< - Parameters[0] - >, -) => - retryRestartAbortedMainSessionRecoveryAfterOwnerReleaseBase({ - gatewayRuntime: mockRecoveryRuntime, - ...params, - }); +const retryRestartAbortedMainSessionRecoveryAfterOwnerRelease = + retryRestartAbortedMainSessionRecovery; const scheduleRestartAbortedMainSessionRecovery = ( params: RecoveryParams[0]>, ) => @@ -2006,6 +1995,60 @@ describe("main-session-restart-recovery", () => { expect(store["agent:main:already-marked"]?.abortedLastRun).toBe(false); }); + it("does not create empty agent databases while scanning startup recovery", async () => { + const agentIds = Array.from({ length: 12 }, (_, index) => `agent-${index + 1}`); + const databasePaths = await Promise.all( + agentIds.map(async (agentId) => { + await makeSessionsDir(agentId); + return path.join(tmpDir, "agents", agentId, "agent", "openclaw-agent.sqlite"); + }), + ); + + await expect(markStartupOrphanedMainSessionsForRecovery({ stateDir: tmpDir })).resolves.toEqual( + { marked: 0, skipped: 0 }, + ); + for (const databasePath of databasePaths) { + await expect(fs.stat(databasePath)).rejects.toMatchObject({ code: "ENOENT" }); + } + }); + + it("does not enter the writer lane for agent databases without running sessions", async () => { + const agentIds = Array.from({ length: 12 }, (_, index) => `agent-${index + 1}`); + const env = { ...process.env, OPENCLAW_STATE_DIR: tmpDir }; + for (const agentId of agentIds) { + openOpenClawAgentDatabase({ + agentId, + env, + path: path.join(tmpDir, "agents", agentId, "agent", "openclaw-agent.sqlite"), + }); + } + closeOpenClawAgentDatabasesForTest(); + const applySessionEntryReplacements = vi.spyOn( + sessionAccessor, + "applySessionEntryReplacements", + ); + + try { + await expect( + markStartupOrphanedMainSessionsForRecovery({ stateDir: tmpDir }), + ).resolves.toEqual({ marked: 0, skipped: 0 }); + expect(applySessionEntryReplacements).not.toHaveBeenCalled(); + } finally { + applySessionEntryReplacements.mockRestore(); + } + }); + + it("keeps corrupt existing agent databases on the startup recovery error path", async () => { + await makeSessionsDir(); + const databasePath = path.join(tmpDir, "agents", "main", "agent", "openclaw-agent.sqlite"); + await fs.mkdir(path.dirname(databasePath), { recursive: true }); + await fs.writeFile(databasePath, "not a sqlite database"); + + await expect( + markStartupOrphanedMainSessionsForRecovery({ stateDir: tmpDir }), + ).rejects.toThrow(); + }); + it.each([ ["current owner before delayed stale registration", "current-first"], ["stale owner before current registration", "stale-first"], @@ -2020,7 +2063,16 @@ describe("main-session-restart-recovery", () => { updatedAt: cutoff - 10_000, status: "running", }, + "agent:main:control": { + sessionId: "control-session", + updatedAt: cutoff - 10_000, + status: "running", + }, }); + await writeTranscript(sessionsDir, "control-session", [ + { role: "user", content: "resume the control session" }, + { role: "toolResult", content: "done" }, + ]); const createHandle = (runId: string): EmbeddedAgentQueueHandle => ({ kind: "embedded", @@ -2044,138 +2096,108 @@ describe("main-session-restart-recovery", () => { setActiveEmbeddedRun(sessionId, staleHandle, sessionKey); } + const recovery = scheduleRestartAbortedMainSessionRecovery({ + cfg: {}, + delayMs: 0, + stateDir: tmpDir, + }); try { - await expect( - recoverStartupOrphanedMainSessions({ stateDir: tmpDir, updatedBeforeMs: cutoff }), - ).resolves.toEqual({ marked: 0, recovered: 0, failed: 0, skipped: 0 }); - expect(callGateway).not.toHaveBeenCalled(); - expect( - loadSessionEntry({ - sessionKey, - storePath: path.join(sessionsDir, "sessions.json"), - }), - ).toMatchObject({ status: "running" }); + await waitForFast(() => + expect( + loadSessionEntry({ + sessionKey: "agent:main:control", + storePath: path.join(sessionsDir, "sessions.json"), + }), + ).toMatchObject({ abortedLastRun: false }), + ); + await recovery.stop(); + + expect(callGateway).toHaveBeenCalledOnce(); + const activeEntry = loadSessionEntry({ + sessionKey, + storePath: path.join(sessionsDir, "sessions.json"), + }); + expect(activeEntry).toMatchObject({ status: "running" }); + expect(activeEntry?.abortedLastRun).toBeUndefined(); } finally { + await recovery.stop(); clearActiveEmbeddedRun(sessionId, currentHandle, sessionKey); clearActiveEmbeddedRun(sessionId, staleHandle, sessionKey); } }); - it("reconciles only prior-lifecycle running sessions after an in-process restart", async () => { + it("cancels a stale startup owner after a mid-scan lifecycle rotation", async () => { const sessionsDir = await makeSessionsDir(); - const cutoff = Date.now(); - const abandonedKey = "agent:main:abandoned-client"; - const liveKey = "agent:main:live-client"; + const storePath = path.join(sessionsDir, "sessions.json"); + const sessionKey = "agent:main:generation-race"; + const sessionId = "generation-race-session"; await writeStore(sessionsDir, { - [abandonedKey]: { - sessionId: "abandoned-session", - updatedAt: cutoff - 10_000, - status: "running", - }, - [liveKey]: { - sessionId: "live-session", - updatedAt: cutoff - 10_000, + [sessionKey]: { + sessionId, + updatedAt: Date.now() - 10_000, status: "running", }, }); - await writeTranscript(sessionsDir, "abandoned-session", [ - { role: "system", content: "the client disappeared before the turn became resumable" }, - ]); - const createHandle = ( - runId: string, - queueMessage: EmbeddedAgentQueueHandle["queueMessage"] = async () => {}, - abort: EmbeddedAgentQueueHandle["abort"] = () => {}, - ): EmbeddedAgentQueueHandle => ({ + const originalApply = sessionAccessor.applySessionEntryReplacements; + const markerEntered = createDeferred(); + const releaseMarker = createDeferred(); + let pausedMarker = false; + const replacementSpy = vi + .spyOn(sessionAccessor, "applySessionEntryReplacements") + .mockImplementation(async (params) => { + if (params.requireWriteSuccess === true && !pausedMarker) { + pausedMarker = true; + markerEntered.resolve(); + await releaseMarker.promise; + } + return await originalApply(params); + }); + const recovery = scheduleRestartAbortedMainSessionRecovery({ + cfg: {}, + delayMs: 0, + stateDir: tmpDir, + }); + await markerEntered.promise; + expect(getActiveGatewayRootWorkCount()).toBe(1); + + // Rotate while the production scheduler owns the startup scan. Its marker + // must re-read the replacement generation's owner before writing, while + // stop waits for that in-flight root-work admission to leave cleanly. + rotateAgentEventLifecycleGeneration(); + const liveAbort = vi.fn(); + const liveHandle: EmbeddedAgentQueueHandle = { kind: "embedded", - runId, - queueMessage, + runId: "live-run", + queueMessage: async () => {}, isStreaming: () => true, isCompacting: () => false, - abort, + abort: liveAbort, + }; + setActiveEmbeddedRun(sessionId, liveHandle, sessionKey); + let stopSettled = false; + const stopping = recovery.stop().then(() => { + stopSettled = true; }); - const abandonedReply = createReplyOperation({ - sessionKey: abandonedKey, - sessionId: "abandoned-session", - resetTriggered: false, - }); - const abandonedReplyQueue = vi.fn(async () => {}); - const abandonedReplyCancel = vi.fn(); - abandonedReply.setPhase("running"); - abandonedReply.attachBackend({ - kind: "embedded", - cancel: abandonedReplyCancel, - isStreaming: () => true, - queueMessage: abandonedReplyQueue, - }); - const abandonedEmbeddedQueue = vi.fn(async () => {}); - const abandonedEmbeddedAbort = vi.fn(); - const abandonedHandle = createHandle( - "abandoned-run", - abandonedEmbeddedQueue, - abandonedEmbeddedAbort, - ); - setActiveEmbeddedRun("abandoned-session", abandonedHandle, abandonedKey); - - const firstRecovery = recoverStartupOrphanedMainSessions({ - stateDir: tmpDir, - updatedBeforeMs: cutoff, - }); - // Advance ownership while the async store discovery above is pending. The - // older scan must drop the stale owner without overlooking this new live one. - rotateAgentEventLifecycleGeneration(); - setActiveEmbeddedRun("abandoned-session", abandonedHandle, abandonedKey); - - await expect( - queueEmbeddedAgentMessageWithOutcomeAsync("abandoned-session", "do not route stale"), - ).resolves.toMatchObject({ queued: false, reason: "no_active_run" }); - expect(abandonedEmbeddedQueue).not.toHaveBeenCalled(); - expect(abandonedEmbeddedAbort).toHaveBeenCalledWith("restart"); - expect(abandonedReplyQueue).not.toHaveBeenCalled(); - expect(abandonedReplyCancel).toHaveBeenCalledWith("restart"); - expect(resolveActiveEmbeddedRunHandleSessionId(abandonedKey)).toBeUndefined(); - - const liveReply = createReplyOperation({ - sessionKey: liveKey, - sessionId: "live-session", - resetTriggered: false, - }); - const liveAbort = vi.fn(); - const liveHandle = createHandle("live-run", undefined, liveAbort); - setActiveEmbeddedRun("live-session", liveHandle, liveKey); try { - const first = await firstRecovery; + await Promise.resolve(); + expect(stopSettled).toBe(false); + + releaseMarker.resolve(); + await stopping; - expect(first).toEqual({ marked: 1, recovered: 0, failed: 1, skipped: 0 }); expect(callGateway).not.toHaveBeenCalled(); - expect( - loadSessionEntry({ - sessionKey: abandonedKey, - storePath: path.join(sessionsDir, "sessions.json"), - }), - ).toMatchObject({ - status: "failed", - abortedLastRun: true, - }); - expect( - loadSessionEntry({ - sessionKey: liveKey, - storePath: path.join(sessionsDir, "sessions.json"), - }), - ).toMatchObject({ - status: "running", - }); + const entry = loadSessionEntry({ sessionKey, storePath }); + expect(entry).toMatchObject({ status: "running" }); + expect(entry?.abortedLastRun).toBeUndefined(); + expect(entry?.mainRestartRecovery).toBeUndefined(); expect(liveAbort).not.toHaveBeenCalled(); - expect(liveReply.abortSignal.aborted).toBe(false); - - await expect( - recoverStartupOrphanedMainSessions({ stateDir: tmpDir, updatedBeforeMs: cutoff }), - ).resolves.toEqual({ marked: 0, recovered: 0, failed: 0, skipped: 0 }); + expect(getActiveGatewayRootWorkCount()).toBe(0); } finally { - clearActiveEmbeddedRun("abandoned-session", abandonedHandle, abandonedKey); - clearActiveEmbeddedRun("live-session", liveHandle, liveKey); - abandonedReply.complete(); - liveReply.complete(); + releaseMarker.resolve(); + await stopping; + replacementSpy.mockRestore(); + clearActiveEmbeddedRun(sessionId, liveHandle, sessionKey); } }); @@ -2207,16 +2229,22 @@ describe("main-session-restart-recovery", () => { { role: "toolResult", content: "custom result" }, ]); - const result = await recoverStartupOrphanedMainSessions({ + const recovery = scheduleRestartAbortedMainSessionRecovery({ cfg: { session: { store: customStorePath } }, + delayMs: 0, stateDir: tmpDir, - updatedBeforeMs: cutoff, }); + try { + await waitForFast(() => + expect( + loadSessionEntry({ sessionKey: "agent:main:main", storePath: customStorePath }), + ).toMatchObject({ abortedLastRun: false }), + ); + await recovery.stop(); + } finally { + await recovery.stop(); + } - expect(result).toMatchObject({ marked: 2, recovered: 1, failed: 0 }); - // Discovery can revisit the non-routable default store through a canonical path alias. - expect(result.skipped).toBeGreaterThanOrEqual(1); - expect(result.skipped).toBeLessThanOrEqual(2); expect(callGateway).toHaveBeenCalledOnce(); const defaultStore = readStore(path.join(defaultSessionsDir, "sessions.json")); const customStore = readStore(customStorePath); @@ -2602,12 +2630,32 @@ describe("main-session-restart-recovery", () => { await retryScheduled.promise; expect(countAgentDispatches()).toBe(1); + await writeStore(sessionsDir, { + "agent:main:late-startup-row": { + sessionId: "late-startup-session", + updatedAt: 1, + status: "running", + }, + }); + await writeTranscript(sessionsDir, "late-startup-session", [ + { role: "user", content: "this row appeared after startup marking" }, + { role: "toolResult", content: "done" }, + ]); + await vi.advanceTimersByTimeAsync(4_999); expect(countAgentDispatches()).toBe(1); await vi.advanceTimersByTimeAsync(1); await secondDispatch.promise; + await recovery.stop(); + expect(countAgentDispatches()).toBe(2); + const lateEntry = loadSessionEntry({ + sessionKey: "agent:main:late-startup-row", + storePath: path.join(sessionsDir, "sessions.json"), + }); + expect(lateEntry).toMatchObject({ status: "running" }); + expect(lateEntry?.abortedLastRun).toBeUndefined(); } finally { await recovery?.stop(); setTimeoutSpy.mockRestore(); diff --git a/src/agents/main-session-restart-recovery.ts b/src/agents/main-session-restart-recovery.ts index ddcfedad416f..eaadd438431e 100644 --- a/src/agents/main-session-restart-recovery.ts +++ b/src/agents/main-session-restart-recovery.ts @@ -8,9 +8,7 @@ export { } from "./main-session-restart-recovery-marking.js"; export { recoverRestartAbortedMainSessions, - recoverStartupOrphanedMainSessions, retryRestartAbortedMainSessionRecovery, - retryRestartAbortedMainSessionRecoveryAfterOwnerRelease, scheduleRestartAbortedMainSessionRecovery, scheduleRestartAbortedMainSessionRecoveryAfterOwnerRelease, } from "./main-session-restart-recovery-runtime.js"; diff --git a/src/agents/usage.ts b/src/agents/usage.ts index c0a7e51b0e80..649666cc9368 100644 --- a/src/agents/usage.ts +++ b/src/agents/usage.ts @@ -73,6 +73,18 @@ export type OpenAiChatCompletionsUsage = { completion_tokens_details?: { reasoning_tokens: number }; }; +/** OpenAI Responses compatible usage shape. */ +type OpenAiResponsesUsage = { + input_tokens: number; + input_tokens_details: { + cached_tokens: number; + cache_write_tokens: number; + }; + output_tokens: number; + output_tokens_details: { reasoning_tokens: number }; + total_tokens: number; +}; + /** Assistant usage snapshot with token counts and computed cost buckets. */ export type AssistantUsageSnapshot = Usage; @@ -272,6 +284,35 @@ export function toOpenAiChatCompletionsUsage( }; } +/** + * Maps normalized usage to OpenAI Responses `usage` fields. + * + * Responses reports cache reads and writes as subsets of `input_tokens`, so + * recombine OpenClaw's separately priced buckets and retain their details. + * Reasoning tokens remain a detail of `output_tokens`, not an extra bucket. + */ +export function toOpenAiResponsesUsage(usage: NormalizedUsage | undefined): OpenAiResponsesUsage { + const input = Math.max(0, usage?.input ?? 0); + const output = Math.max(0, usage?.output ?? 0); + const cacheRead = Math.max(0, usage?.cacheRead ?? 0); + const cacheWrite = Math.max(0, usage?.cacheWrite ?? 0); + const reasoningTokens = Math.max(0, usage?.reasoningTokens ?? 0); + const inputTokens = input + cacheRead + cacheWrite; + const componentTotal = inputTokens + output; + const aggregateTotal = Math.max(0, usage?.total ?? 0); + + return { + input_tokens: inputTokens, + input_tokens_details: { + cached_tokens: cacheRead, + cache_write_tokens: cacheWrite, + }, + output_tokens: output, + output_tokens_details: { reasoning_tokens: reasoningTokens }, + total_tokens: Math.max(componentTotal, aggregateTotal), + }; +} + /** Derive prompt/context tokens from normalized input and cache buckets. */ export function derivePromptTokens(usage?: { input?: number; diff --git a/src/auto-reply/reply/commands-goal.test.ts b/src/auto-reply/reply/commands-goal.test.ts index ccc481d61226..325921d6aafd 100644 --- a/src/auto-reply/reply/commands-goal.test.ts +++ b/src/auto-reply/reply/commands-goal.test.ts @@ -7,11 +7,7 @@ import { loadSessionEntry, replaceSessionEntry } from "../../config/sessions/ses import type { SessionEntry } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { takeCommandSessionMetadataChanges } from "./command-session-metadata.js"; -import { - formatGoalContinuationPrompt, - handleGoalCommand, - parseGoalCommand, -} from "./commands-goal.js"; +import { handleGoalCommand, parseGoalCommand } from "./commands-goal.js"; import type { HandleCommandsParams } from "./commands-types.js"; import { parseInlineDirectives } from "./directive-handling.parse.js"; @@ -108,18 +104,6 @@ describe("goal commands", () => { }); }); - it("formats command-looking continuation prompts so inline directives leave them intact", () => { - const prompt = formatGoalContinuationPrompt("ship /fast off"); - expect(prompt).toBe( - `Pursue this goal exactly as written from this JSON string: "ship \\/fast off"`, - ); - - const directives = parseInlineDirectives(prompt); - - expect(directives.cleaned).toBe(prompt); - expect(directives.hasFastDirective).toBe(false); - }); - it("starts a goal from Codex-style bare /goal objective text", async () => { const storePath = await createStorePath(); await upsertSessionEntry({ diff --git a/src/auto-reply/reply/commands-goal.ts b/src/auto-reply/reply/commands-goal.ts index aabd88a3ae21..e36eaa233fc9 100644 --- a/src/auto-reply/reply/commands-goal.ts +++ b/src/auto-reply/reply/commands-goal.ts @@ -12,6 +12,7 @@ import { updateSessionGoalStatus, } from "../../config/sessions.js"; import { loadSessionEntry as getSessionEntry } from "../../config/sessions/session-accessor.js"; +import type { SessionEntry } from "../../config/sessions/types.js"; import { applyCommandTextToParams } from "./command-context-rewrite.js"; import { commandReply as goalReply, defineAuthorizedTextCommand } from "./command-gates.js"; import { markCommandSessionMetadataChanged } from "./command-session-metadata.js"; @@ -84,16 +85,14 @@ function encodeGoalJsonString(trimmed: string): string { return JSON.stringify(trimmed).replaceAll("/", "\\/"); } -/** Formats the model prompt used to continue a newly started goal. */ -export function formatGoalContinuationPrompt(objective: string): string { +function formatGoalContinuationPrompt(objective: string): string { const trimmed = objective.trim(); return hasCommandLikeGoalText(trimmed) ? `${GOAL_CONTINUATION_PROMPT_PREFIX} ${encodeGoalJsonString(trimmed)}` : trimmed; } -/** Formats the model prompt used when resuming a paused goal. */ -export function formatGoalResumeContinuationPrompt(note: string): string { +function formatGoalResumeContinuationPrompt(note: string): string { const trimmed = note.trim(); if (!trimmed) { return "Continue pursuing the current goal."; @@ -121,135 +120,130 @@ function goalErrorReply(error: unknown): CommandHandlerResult { return goalReply(`Goal error: ${message}`); } +type ParsedGoalCommand = NonNullable>; + +type SessionGoalCommandResult = { + text: string; + continuationPrompt?: string; + changed: boolean; +}; + +/** Execute goal storage policy once for auto-reply, Gateway, and embedded callers. */ +export async function executeSessionGoalCommand(params: { + parsed: ParsedGoalCommand; + sessionKey: string; + storePath?: string; + fallbackEntry?: SessionEntry; + agentId?: string; + readOnlyStatus?: boolean; +}): Promise { + const common = { + sessionKey: params.sessionKey, + storePath: params.storePath, + actor: { type: "human" as const }, + agentId: params.agentId, + }; + const note = params.parsed.text ? { note: params.parsed.text } : {}; + + switch (params.parsed.action) { + case "status": { + const snapshot = await getSessionGoal({ + sessionKey: params.sessionKey, + storePath: params.storePath, + ...(params.readOnlyStatus ? { fallbackEntry: params.fallbackEntry, persist: false } : {}), + }); + return { text: formatSessionGoalStatus(snapshot.goal), changed: false }; + } + case "start": + case "set": + case "create": { + const objective = normalizeOptionalString(params.parsed.text); + if (!objective) { + return { text: "Usage: /goal start ", changed: false }; + } + const goal = await createSessionGoal({ + ...common, + objective, + fallbackEntry: params.fallbackEntry, + }); + return { + text: `Goal started: ${goal.objective}`, + continuationPrompt: formatGoalContinuationPrompt(goal.objective), + changed: true, + }; + } + case "edit": { + const objective = normalizeOptionalString(params.parsed.text); + if (!objective) { + return { text: "Usage: /goal edit ", changed: false }; + } + const goal = await updateSessionGoalObjective({ ...common, objective }); + return { text: `Goal updated: ${goal.objective}`, changed: true }; + } + case "pause": { + const goal = await updateSessionGoalStatus({ ...common, status: "paused", ...note }); + return { text: `Goal paused: ${goal.objective}`, changed: true }; + } + case "resume": { + const goal = await updateSessionGoalStatus({ ...common, status: "active", ...note }); + return { + text: `Goal resumed: ${goal.objective}`, + continuationPrompt: formatGoalResumeContinuationPrompt(params.parsed.text), + changed: true, + }; + } + case "complete": + case "done": { + const goal = await updateSessionGoalStatus({ ...common, status: "complete", ...note }); + return { + text: `Goal complete: ${goal.objective}\nTokens used: ${goal.tokensUsed}`, + changed: true, + }; + } + case "block": + case "blocked": { + const goal = await updateSessionGoalStatus({ ...common, status: "blocked", ...note }); + return { text: `Goal blocked: ${goal.objective}`, changed: true }; + } + case "clear": { + const removed = await clearSessionGoal(common); + return { + text: removed ? "Goal cleared." : "No goal to clear.", + changed: removed, + }; + } + default: + return { + text: "Usage: /goal | /goal [status] | /goal start | /goal edit | /goal pause|resume|complete|block|clear", + changed: false, + }; + } +} + /** Command handler for /goal lifecycle commands. */ export const handleGoalCommand: CommandHandler = defineAuthorizedTextCommand( { label: "/goal", match: parseGoalCommand }, async (params, parsed) => { - const actor = { type: "human" as const }; - const goalAgentId = params.agentId; - try { - switch (parsed.action) { - case "status": { - const snapshot = await getSessionGoal({ - sessionKey: params.sessionKey, - storePath: params.storePath, - fallbackEntry: params.sessionEntry, - persist: false, - }); - syncGoalSessionEntry(params); - return goalReply(formatSessionGoalStatus(snapshot.goal)); - } - case "start": - case "set": - case "create": { - const objective = normalizeOptionalString(parsed.text); - if (!objective) { - return goalReply("Usage: /goal start "); - } - const goal = await createSessionGoal({ - sessionKey: params.sessionKey, - storePath: params.storePath, - objective, - fallbackEntry: params.sessionEntry, - actor, - agentId: goalAgentId, - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - applyCommandTextToParams(params, formatGoalContinuationPrompt(goal.objective)); - return goalContinuation(); - } - case "edit": { - const objective = normalizeOptionalString(parsed.text); - if (!objective) { - return goalReply("Usage: /goal edit "); - } - const goal = await updateSessionGoalObjective({ - sessionKey: params.sessionKey, - storePath: params.storePath, - objective, - actor, - agentId: goalAgentId, - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - return goalReply(`Goal updated: ${goal.objective}`); - } - case "pause": { - const goal = await updateSessionGoalStatus({ - sessionKey: params.sessionKey, - storePath: params.storePath, - status: "paused", - actor, - agentId: goalAgentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - return goalReply(`Goal paused: ${goal.objective}`); - } - case "resume": { - await updateSessionGoalStatus({ - sessionKey: params.sessionKey, - storePath: params.storePath, - status: "active", - actor, - agentId: goalAgentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - const message = formatGoalResumeContinuationPrompt(parsed.text); - applyCommandTextToParams(params, message); - return goalContinuation(); - } - case "complete": - case "done": { - const goal = await updateSessionGoalStatus({ - sessionKey: params.sessionKey, - storePath: params.storePath, - status: "complete", - actor, - agentId: goalAgentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - return goalReply(`Goal complete: ${goal.objective}\nTokens used: ${goal.tokensUsed}`); - } - case "block": - case "blocked": { - const goal = await updateSessionGoalStatus({ - sessionKey: params.sessionKey, - storePath: params.storePath, - status: "blocked", - actor, - agentId: goalAgentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - syncGoalSessionEntry(params); - markCommandSessionMetadataChanged(params); - return goalReply(`Goal blocked: ${goal.objective}`); - } - case "clear": { - const removed = await clearSessionGoal({ - sessionKey: params.sessionKey, - storePath: params.storePath, - actor, - agentId: goalAgentId, - }); - syncGoalSessionEntry(params); - if (removed) { - markCommandSessionMetadataChanged(params); - } - return goalReply(removed ? "Goal cleared." : "No goal to clear."); - } - default: - return goalReply( - "Usage: /goal | /goal [status] | /goal start | /goal edit | /goal pause|resume|complete|block|clear", - ); + const result = await executeSessionGoalCommand({ + parsed, + sessionKey: params.sessionKey, + storePath: params.storePath, + fallbackEntry: params.sessionEntry, + agentId: params.agentId, + readOnlyStatus: true, + }); + if (result.changed || parsed.action === "status" || parsed.action === "clear") { + syncGoalSessionEntry(params); } + if (result.changed) { + markCommandSessionMetadataChanged(params); + } + if (result.continuationPrompt) { + applyCommandTextToParams(params, result.continuationPrompt); + return goalContinuation(); + } + return goalReply(result.text); } catch (error) { return goalErrorReply(error); } diff --git a/src/auto-reply/reply/directive-handling.fast-lane.ts b/src/auto-reply/reply/directive-handling.fast-lane.ts deleted file mode 100644 index be5a1f112392..000000000000 --- a/src/auto-reply/reply/directive-handling.fast-lane.ts +++ /dev/null @@ -1,120 +0,0 @@ -// Applies fast-lane reply directives before normal delivery processing. -import type { ReplyPayload } from "../types.js"; -import { isDirectiveOnly } from "./directive-handling.directive-only.js"; -import { handleDirectiveOnly } from "./directive-handling.impl.js"; -import { resolveCurrentDirectiveLevels } from "./directive-handling.levels.js"; -import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js"; - -export async function applyInlineDirectivesFastLane( - params: ApplyInlineDirectivesFastLaneParams, -): Promise<{ - directiveAck?: ReplyPayload; - provider: string; - model: string; - sessionChangesApplied: boolean; -}> { - const { - directives, - commandAuthorized, - ctx, - cfg, - agentId, - isGroup, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - policyAliasIndex, - allowedModelKeys, - allowedModelCatalog, - resetModelOverride, - formatModelSwitchEvent, - modelState, - } = params; - - let { provider, model } = params; - if ( - !commandAuthorized || - isDirectiveOnly({ - directives, - cleanedBody: directives.cleaned, - ctx, - cfg, - agentId, - isGroup, - }) - ) { - return { directiveAck: undefined, provider, model, sessionChangesApplied: true }; - } - - const agentCfg = params.agentCfg; - const { - currentThinkLevel, - currentFastMode, - currentVerboseLevel, - currentReasoningLevel, - currentElevatedLevel, - } = await resolveCurrentDirectiveLevels({ - sessionEntry, - agentCfg, - resolveDefaultThinkingLevel: directives.hasThinkDirective - ? () => modelState.resolveDefaultThinkingLevel() - : async () => undefined, - }); - - const persistenceState = { sessionChangesApplied: true }; - const directiveAck = await handleDirectiveOnly({ - cfg, - directives, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - policyAliasIndex, - allowedModelKeys, - allowedModelCatalog, - thinkingCatalog: await modelState.resolveThinkingCatalog(), - resetModelOverride, - provider, - model, - initialModelLabel: params.initialModelLabel, - formatModelSwitchEvent, - canPersistStickyModelSelection: params.canPersistStickyModelSelection, - currentThinkLevel, - currentFastMode, - currentVerboseLevel, - currentReasoningLevel, - currentElevatedLevel, - ctx, - messageProvider: ctx.Provider, - surface: ctx.Surface, - gatewayClientScopes: ctx.GatewayClientScopes, - commandAuthorized, - senderIsOwner: params.senderIsOwner, - workspaceDir: params.workspaceDir, - persistenceState, - }); - - if (sessionEntry?.providerOverride) { - provider = sessionEntry.providerOverride; - } - if (sessionEntry?.modelOverride) { - model = sessionEntry.modelOverride; - } - - return { directiveAck, provider, model, ...persistenceState }; -} diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts index 65d279847508..7f2ba0766efc 100644 --- a/src/auto-reply/reply/directive-handling.impl.ts +++ b/src/auto-reply/reply/directive-handling.impl.ts @@ -35,8 +35,11 @@ import { maybeHandleUnexpectedNativeDirectiveArguments } from "./directive-handl import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; import { maybeHandleQueueDirective } from "./directive-handling.queue-validation.js"; import { + acknowledgeIgnoredSessionDirective, applySessionDirectiveFields, canPersistSessionDirectiveDefaults, + DIRECTIVE_ACK_MESSAGES, + type IgnoredSessionDirectiveFlag, formatDirectiveAck, formatElevatedRuntimeHint, formatElevatedUnavailableText, @@ -45,6 +48,7 @@ import { formatInternalVerbosePersistenceDeniedText, enqueueModeSwitchEvents, persistSessionDirectiveSnapshot, + rejectSessionDirectiveTransaction, resolveDirectiveTouchedSessionFields, withOptions, } from "./directive-handling.shared.js"; @@ -52,30 +56,6 @@ import type { ReasoningLevel, ThinkLevel } from "./directives.js"; import { refreshQueuedFollowupSession } from "./queue.js"; import { resolveRuntimePolicySessionKey } from "./runtime-policy-session-key.js"; -const DIRECTIVE_ACK_MESSAGES = { - verbose: { - off: "Verbose logging disabled.", - on: "Verbose logging enabled.", - full: "Verbose logging set to full.", - }, - trace: { - off: "Trace disabled.", - on: "Trace enabled. Warning: trace output may contain sensitive information.", - raw: "Trace set to raw. Warning: trace output may contain sensitive information.", - }, - reasoning: { - off: "Reasoning visibility disabled.", - on: "Reasoning visibility enabled.", - stream: "Reasoning stream enabled.", - }, - elevated: { - off: "Elevated mode disabled.", - on: "Elevated mode set to ask (approvals may still apply).", - ask: "Elevated mode set to ask (approvals may still apply).", - full: "Elevated mode set to full (auto-approve).", - }, -} as const; - /** Handles inline directives that can be acknowledged without a model turn. */ export async function handleDirectiveOnly( params: HandleDirectiveOnlyParams, @@ -105,11 +85,28 @@ export async function handleDirectiveOnly( currentReasoningLevel, currentElevatedLevel, } = params; + const allowPrivilegedPersistence = canPersistSessionDirectiveDefaults(params); + const rejectModelTransaction = (errorText: string) => + rejectSessionDirectiveTransaction(params.persistenceState, errorText); + const acknowledgeIgnoredDirective = ( + reply: ReplyPayload, + ignoredDirective: IgnoredSessionDirectiveFlag, + ) => + acknowledgeIgnoredSessionDirective({ + reply, + directives, + ignoredDirective, + persistenceState: params.persistenceState, + allowPrivilegedPersistence, + applyRemainingDirectives: (remainingDirectives) => + handleDirectiveOnly({ ...params, directives: remainingDirectives }), + }); const delegatedTraceAllowed = (params.gatewayClientScopes ?? []).includes("operator.admin"); if (directives.hasTraceDirective && !params.senderIsOwner && !delegatedTraceAllowed) { - return { - text: "❌ /trace is restricted to owners and gateway clients with operator.admin scope.", - }; + return acknowledgeIgnoredDirective( + { text: "❌ /trace is restricted to owners and gateway clients with operator.admin scope." }, + "hasTraceDirective", + ); } const activeAgentId = resolveSessionAgentId({ sessionKey: params.sessionKey, @@ -126,13 +123,6 @@ export async function handleDirectiveOnly( sessionKey: runtimePolicySessionKey, }).sandboxed; const shouldHintDirectRuntime = directives.hasElevatedDirective && !runtimeIsSandboxed; - const allowPrivilegedPersistence = canPersistSessionDirectiveDefaults({ - messageProvider: params.messageProvider, - surface: params.surface, - gatewayClientScopes: params.gatewayClientScopes, - commandAuthorized: params.commandAuthorized, - senderIsOwner: params.senderIsOwner, - }); const thinkingCatalog = params.thinkingCatalog && params.thinkingCatalog.length > 0 ? params.thinkingCatalog @@ -161,7 +151,7 @@ export async function handleDirectiveOnly( sessionEntry, }); if (modelInfo) { - return modelInfo; + return acknowledgeIgnoredDirective(modelInfo, "hasModelDirective"); } const modelResolution = resolveModelSelectionFromDirective({ @@ -177,12 +167,12 @@ export async function handleDirectiveOnly( agentId: activeAgentId, }); if (modelResolution.errorText) { - return { text: modelResolution.errorText }; + return rejectModelTransaction(modelResolution.errorText); } const modelSelection = modelResolution.modelSelection; const profileOverride = modelResolution.profileOverride; if (modelSelection && isModelSelectionLocked(sessionEntry)) { - return { text: MODEL_SELECTION_LOCKED_MESSAGE }; + return rejectModelTransaction(MODEL_SELECTION_LOCKED_MESSAGE); } const resolvedProvider = modelSelection?.provider ?? provider; @@ -196,7 +186,7 @@ export async function handleDirectiveOnly( }) : ({ kind: "unchanged" } as const); if (modelRuntimeResolution.kind === "invalid") { - return { text: modelRuntimeResolution.errorText }; + return rejectModelTransaction(modelRuntimeResolution.errorText); } const prospectiveSessionEntry = { ...sessionEntry }; applyModelRuntimeDirective(prospectiveSessionEntry, modelRuntimeResolution); @@ -232,30 +222,45 @@ export async function handleDirectiveOnly( catalog: thinkingCatalog, agentRuntime: thinkingRuntime, }); - return { - text: withOptions( - `Current thinking level: ${level}.`, - `default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}`, - ), - }; + return acknowledgeIgnoredDirective( + { + text: withOptions( + `Current thinking level: ${level}.`, + `default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}`, + ), + }, + "hasThinkDirective", + ); } - return { - text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`, - }; + return acknowledgeIgnoredDirective( + { + text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`, + }, + "hasThinkDirective", + ); } if (directives.hasVerboseDirective && !directives.verboseLevel) { - return { - text: directives.rawVerboseLevel - ? `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on, full.` - : withOptions(`Current verbose level: ${currentVerboseLevel ?? "off"}.`, "on, full, off"), - }; + return acknowledgeIgnoredDirective( + { + text: directives.rawVerboseLevel + ? `Unrecognized verbose level "${directives.rawVerboseLevel}". Valid levels: off, on, full.` + : withOptions(`Current verbose level: ${currentVerboseLevel ?? "off"}.`, "on, full, off"), + }, + "hasVerboseDirective", + ); } if (directives.hasTraceDirective && !directives.traceLevel) { - return { - text: directives.rawTraceLevel - ? `Unrecognized trace level "${directives.rawTraceLevel}". Valid levels: off, on, raw.` - : withOptions(`Current trace level: ${sessionEntry.traceLevel ?? "off"}.`, "on, off, raw"), - }; + return acknowledgeIgnoredDirective( + { + text: directives.rawTraceLevel + ? `Unrecognized trace level "${directives.rawTraceLevel}". Valid levels: off, on, raw.` + : withOptions( + `Current trace level: ${sessionEntry.traceLevel ?? "off"}.`, + "on, off, raw", + ), + }, + "hasTraceDirective", + ); } if ( directives.hasFastDirective && @@ -269,64 +274,85 @@ export async function handleDirectiveOnly( source: effectiveFastModeSource, fastAutoOnSeconds: fastModeState.fastAutoOnSeconds, }); - return { - text: isFastStatus - ? statusText - : withOptions( - statusText, - formatFastModeCommandOptions({ - fastAutoOnSeconds: fastModeState.fastAutoOnSeconds, - }), - ), - }; + return acknowledgeIgnoredDirective( + { + text: isFastStatus + ? statusText + : withOptions( + statusText, + formatFastModeCommandOptions({ + fastAutoOnSeconds: fastModeState.fastAutoOnSeconds, + }), + ), + }, + "hasFastDirective", + ); } - return { - text: `Unrecognized fast mode "${directives.rawFastMode}". Valid levels: on, off, auto, default, status.`, - }; + return acknowledgeIgnoredDirective( + { + text: `Unrecognized fast mode "${directives.rawFastMode}". Valid levels: on, off, auto, default, status.`, + }, + "hasFastDirective", + ); } if (directives.hasReasoningDirective && !directives.reasoningLevel) { - return { - text: directives.rawReasoningLevel - ? `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.` - : withOptions( - `Current reasoning level: ${currentReasoningLevel ?? "off"}.`, - "on, off, stream", - ), - }; + return acknowledgeIgnoredDirective( + { + text: directives.rawReasoningLevel + ? `Unrecognized reasoning level "${directives.rawReasoningLevel}". Valid levels: on, off, stream.` + : withOptions( + `Current reasoning level: ${currentReasoningLevel ?? "off"}.`, + "on, off, stream", + ), + }, + "hasReasoningDirective", + ); } if (directives.hasElevatedDirective && !directives.elevatedLevel) { if (!directives.rawElevatedLevel) { if (!elevatedEnabled || !elevatedAllowed) { - return { - text: formatElevatedUnavailableText({ - runtimeSandboxed: runtimeIsSandboxed, - failures: params.elevatedFailures, - sessionKey: params.sessionKey, - }), - }; + return acknowledgeIgnoredDirective( + { + text: formatElevatedUnavailableText({ + runtimeSandboxed: runtimeIsSandboxed, + failures: params.elevatedFailures, + sessionKey: params.sessionKey, + }), + }, + "hasElevatedDirective", + ); } const level = currentElevatedLevel ?? "off"; - return { - text: [ - withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"), - shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, - ] - .filter(Boolean) - .join("\n"), - }; + return acknowledgeIgnoredDirective( + { + text: [ + withOptions(`Current elevated level: ${level}.`, "on, off, ask, full"), + shouldHintDirectRuntime ? formatElevatedRuntimeHint() : null, + ] + .filter(Boolean) + .join("\n"), + }, + "hasElevatedDirective", + ); } - return { - text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`, - }; + return acknowledgeIgnoredDirective( + { + text: `Unrecognized elevated level "${directives.rawElevatedLevel}". Valid levels: off, on, ask, full.`, + }, + "hasElevatedDirective", + ); } if (directives.hasElevatedDirective && (!elevatedEnabled || !elevatedAllowed)) { - return { - text: formatElevatedUnavailableText({ - runtimeSandboxed: runtimeIsSandboxed, - failures: params.elevatedFailures, - sessionKey: params.sessionKey, - }), - }; + return acknowledgeIgnoredDirective( + { + text: formatElevatedUnavailableText({ + runtimeSandboxed: runtimeIsSandboxed, + failures: params.elevatedFailures, + sessionKey: params.sessionKey, + }), + }, + "hasElevatedDirective", + ); } if (directives.hasExecDirective) { const invalidExecMessage = directives.invalidExecHost @@ -339,7 +365,7 @@ export async function handleDirectiveOnly( ? "Exec node requires a value." : undefined; if (invalidExecMessage) { - return { text: invalidExecMessage }; + return acknowledgeIgnoredDirective({ text: invalidExecMessage }, "hasExecDirective"); } const unexpectedExecArguments = maybeHandleUnexpectedNativeDirectiveArguments(directives); if (unexpectedExecArguments) { @@ -353,12 +379,15 @@ export async function handleDirectiveOnly( sandboxAvailable: runtimeIsSandboxed, }); const nodeLabel = execDefaults.node ? `node=${execDefaults.node}` : "node=(unset)"; - return { - text: withOptions( - `Current exec defaults: host=${renderExecTargetLabel(execDefaults.host)}, effective=${execDefaults.effectiveHost}, security=${execDefaults.security}, ask=${execDefaults.ask}, ${nodeLabel}.`, - "host=auto|sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=", - ), - }; + return acknowledgeIgnoredDirective( + { + text: withOptions( + `Current exec defaults: host=${renderExecTargetLabel(execDefaults.host)}, effective=${execDefaults.effectiveHost}, security=${execDefaults.security}, ask=${execDefaults.ask}, ${nodeLabel}.`, + "host=auto|sandbox|gateway|node, security=deny|allowlist|full, ask=off|on-miss|always, node=", + ), + }, + "hasExecDirective", + ); } } @@ -369,7 +398,7 @@ export async function handleDirectiveOnly( sessionEntry, }); if (queueAck) { - return queueAck; + return acknowledgeIgnoredDirective(queueAck, "hasQueueDirective"); } const unexpectedNativeArguments = maybeHandleUnexpectedNativeDirectiveArguments(directives); @@ -388,14 +417,13 @@ export async function handleDirectiveOnly( agentRuntime: thinkingRuntime, }) ) { - return { - text: `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`, - }; + return rejectModelTransaction( + `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`, + ); } - const resolvedDirectiveThinkLevel = directives.thinkLevel; const nextThinkLevel = directives.hasThinkDirective - ? resolvedDirectiveThinkLevel + ? directives.thinkLevel : ((sessionEntry?.thinkingLevel as ThinkLevel | undefined) ?? currentThinkLevel); const remappedUnsupportedThinkLevel = !directives.hasThinkDirective && @@ -423,11 +451,10 @@ export async function handleDirectiveOnly( const elevatedChanged = directives.hasElevatedDirective && directives.elevatedLevel !== undefined && + directives.elevatedLevel !== (currentElevatedLevel ?? sessionEntry.elevatedLevel ?? "off") && elevatedEnabled && elevatedAllowed; let modelSelectionUpdated = false; - let modelSelectionApplied = true; - let sessionChangesApplied = true; const appliedSessionEntry = sessionEntry; const touchedSessionFields = resolveDirectiveTouchedSessionFields({ directives, @@ -484,27 +511,19 @@ export async function handleDirectiveOnly( modelSelectionUpdated && sessionEntry.liveModelSwitchPending === true, touchedFields: touchedSessionFields, }); - sessionChangesApplied = persistence.sessionChangesApplied; - modelSelectionApplied = persistence.modelSelectionApplied; - } - if (modelSelection && !modelSelectionApplied) { - sessionChangesApplied = false; - } - if (!sessionChangesApplied) { - if (params.persistenceState) { - params.persistenceState.sessionChangesApplied = false; + if (persistence.status !== "applied") { + const errorText = + persistence.status === "model-selection-locked" + ? MODEL_SELECTION_LOCKED_MESSAGE + : modelSelection + ? "Model change was not applied because the session changed. Retry." + : "Session settings were not applied because the session changed. Retry."; + return rejectModelTransaction(errorText); } - return { - text: modelSelection - ? "Model change was not applied because the session changed. Retry." - : "Session settings were not applied because the session changed. Retry.", - }; } if ( modelSelection && - modelSelectionApplied && !modelSelection.isDefault && - !params.persistenceState && params.canPersistStickyModelSelection === true ) { persistStickyModelSelectionBestEffort({ @@ -512,7 +531,7 @@ export async function handleDirectiveOnly( model: `${modelSelection.provider}/${modelSelection.model}`, }); } - if (modelSelection && modelSelectionUpdated && modelSelectionApplied && sessionKey) { + if (modelSelection && modelSelectionUpdated && sessionKey) { triggerSessionPatchHook({ cfg: params.cfg, sessionEntry: appliedSessionEntry, @@ -549,7 +568,7 @@ export async function handleDirectiveOnly( }); } } - if (modelSelection && modelSelectionApplied) { + if (modelSelection) { const nextLabel = `${modelSelection.provider}/${modelSelection.model}`; if (nextLabel !== initialModelLabel) { enqueueSystemEvent(formatModelSwitchEvent(nextLabel, modelSelection.alias), { @@ -565,22 +584,23 @@ export async function handleDirectiveOnly( elevatedChanged, reasoningChanged, }); + if (params.persistenceState) { + params.persistenceState.outcome = { + kind: "applied", + provider: resolvedProvider, + model: resolvedModel, + }; + } const parts: string[] = []; if (directives.clearThinkLevel) { parts.push("Thinking level reset to default."); } else if (directives.hasThinkDirective && directives.thinkLevel) { - const displayedThinkLevel = resolvedDirectiveThinkLevel ?? directives.thinkLevel; parts.push( - displayedThinkLevel === "off" + directives.thinkLevel === "off" ? "Thinking disabled." - : `Thinking level set to ${displayedThinkLevel}.`, + : `Thinking level set to ${directives.thinkLevel}.`, ); - if (directives.thinkLevel === "max" && displayedThinkLevel !== "max") { - parts.push( - `max not supported for ${resolvedProvider}/${resolvedModel}; using ${displayedThinkLevel}.`, - ); - } } if (directives.clearFastMode) { parts.push(formatDirectiveAck("Fast mode reset to default.")); @@ -630,7 +650,7 @@ export async function handleDirectiveOnly( if (directives.hasExecDirective && directives.hasExecOptions && !allowPrivilegedPersistence) { parts.push(formatDirectiveAck(formatInternalExecPersistenceDeniedText())); } - if (modelSelection && modelSelectionApplied) { + if (modelSelection) { const label = `${modelSelection.provider}/${modelSelection.model}`; const labelWithAlias = modelSelection.alias ? `${modelSelection.alias} (${label})` : label; parts.push( @@ -646,8 +666,6 @@ export async function handleDirectiveOnly( } else if (modelRuntimeResolution.kind === "set") { parts.push(`Runtime set to ${modelRuntimeResolution.runtime} for this session.`); } - } else if (modelSelection) { - parts.push("Model change was not applied because the session changed. Retry."); } // Report the model change before the thinking remap it triggered: the remap is a // consequence of the model switch, so the cause should be announced first. diff --git a/src/auto-reply/reply/directive-handling.mixed-inline.test.ts b/src/auto-reply/reply/directive-handling.mixed-inline.test.ts index 60b46482b661..2f211efab658 100644 --- a/src/auto-reply/reply/directive-handling.mixed-inline.test.ts +++ b/src/auto-reply/reply/directive-handling.mixed-inline.test.ts @@ -1,12 +1,25 @@ -// Tests mixed inline directives in user text and command bodies. +// Tests mixed directives through the real reply admission and transaction boundary. import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; +import { persistStickyModelSelectionBestEffort } from "../../agents/sticky-model-selection.js"; import type { OpenClawConfig } from "../../config/config.js"; import type { SessionEntry } from "../../config/sessions.js"; -import { applyInlineDirectivesFastLane } from "./directive-handling.fast-lane.js"; +import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; +import { enqueueSystemEvent } from "../../infra/system-events.js"; +import { MODEL_SELECTION_LOCKED_MESSAGE } from "../../sessions/model-overrides.js"; import { parseInlineDirectives } from "./directive-handling.parse.js"; -import { persistInlineDirectives } from "./directive-handling.persist.js"; +import { applyInlineDirectiveOverrides } from "./get-reply-directives-apply.js"; import { refreshQueuedFollowupSession } from "./queue.js"; +type PersistenceResult = + | { status: "current"; entry: SessionEntry } + | { status: "model-selection-locked"; entry: SessionEntry } + | { status: "lifecycle-invalidated"; error: string; entry?: SessionEntry }; + +const persistenceMocks = vi.hoisted(() => ({ + persist: vi.fn<(params: { entry: SessionEntry }) => Promise>(), +})); + vi.mock("../../agents/agent-scope.js", () => ({ listAgentEntries: vi.fn(() => []), resolveAgentConfig: vi.fn(() => ({})), @@ -20,6 +33,14 @@ vi.mock("../../agents/sandbox.js", () => ({ resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })), })); +vi.mock("../../agents/sticky-model-selection.js", () => ({ + persistStickyModelSelectionBestEffort: vi.fn(), +})); + +vi.mock("../../gateway/session-patch-hooks.js", () => ({ + triggerSessionPatchHook: vi.fn(), +})); + vi.mock("../../infra/system-events.js", () => ({ enqueueSystemEvent: vi.fn(), })); @@ -28,213 +49,190 @@ vi.mock("./queue.js", () => ({ refreshQueuedFollowupSession: vi.fn(), })); +vi.mock("./session-entry-persistence.js", () => ({ + persistReplySessionEntry: (params: { entry: SessionEntry }) => persistenceMocks.persist(params), +})); + function createSessionEntry(overrides?: Partial): SessionEntry { - return { - sessionId: "session-1", - updatedAt: Date.now(), - ...overrides, - }; + return { sessionId: "session-1", updatedAt: 1, ...overrides }; } -function createConfig(): OpenClawConfig { - return { - commands: { text: true }, - agents: { defaults: {} }, - } as unknown as OpenClawConfig; +async function applyMixedDirectives(params: { + body: string; + cfg?: OpenClawConfig; + sessionEntry?: SessionEntry; + sessionKey?: string; + storePath?: string; + channel?: string; + provider?: string; + model?: string; + defaultProvider?: string; + defaultModel?: string; + allowedModels?: ModelCatalogEntry[]; + senderIsOwner?: boolean; + gatewayClientScopes?: string[]; +}) { + const cfg = + params.cfg ?? ({ commands: { text: true }, agents: { defaults: {} } } as OpenClawConfig); + const provider = params.provider ?? "anthropic"; + const model = params.model ?? "claude-opus-4-6"; + const channel = params.channel ?? "telegram"; + const sessionKey = params.sessionKey ?? "agent:main:dm:1"; + const sessionEntry = params.sessionEntry ?? createSessionEntry(); + const sessionStore = { [sessionKey]: sessionEntry }; + const directives = parseInlineDirectives(params.body); + const allowedModels = params.allowedModels ?? []; + const modelState: Parameters[0]["modelState"] = { + provider, + model, + requestedRouteResolution: "resolved", + allowedModelKeys: new Set(allowedModels.map((entry) => `${entry.provider}/${entry.id}`)), + allowedModelCatalog: allowedModels, + policyAliasIndex: { byAlias: new Map(), byKey: new Map() }, + resetModelOverride: false, + resolveThinkingCatalog: async () => allowedModels, + resolveDefaultThinkingLevel: async () => "off", + resolveDefaultReasoningLevel: async () => "off", + needsModelCatalog: false, + }; + const typing = { + onReplyStart: async () => {}, + startTypingLoop: async () => {}, + startTypingOnText: async () => {}, + refreshTypingTtl: () => {}, + isActive: () => false, + markRunComplete: () => {}, + markDispatchIdle: () => {}, + cleanup: vi.fn(), + }; + + const result = await applyInlineDirectiveOverrides({ + ctx: { + Body: params.body, + Provider: channel, + Surface: channel, + ...(params.gatewayClientScopes ? { GatewayClientScopes: params.gatewayClientScopes } : {}), + }, + cfg, + agentId: "main", + agentDir: "/tmp/agent", + workspaceDir: "/tmp/workspace", + agentCfg: cfg.agents?.defaults ?? {}, + sessionEntry, + sessionStore, + sessionKey, + storePath: params.storePath, + sessionScope: undefined, + isGroup: false, + allowTextCommands: true, + command: { + surface: channel, + channel, + ownerList: [], + senderIsOwner: params.senderIsOwner ?? false, + isAuthorizedSender: true, + rawBodyNormalized: params.body, + commandBodyNormalized: params.body, + }, + directives, + messageProviderKey: channel, + elevatedEnabled: true, + elevatedAllowed: true, + elevatedFailures: [], + defaultProvider: params.defaultProvider ?? provider, + defaultModel: params.defaultModel ?? model, + aliasIndex: { byAlias: new Map(), byKey: new Map() }, + provider, + model, + modelState, + initialModelLabel: `${provider}/${model}`, + formatModelSwitchEvent: (label) => `Model switched to ${label}.`, + resolvedElevatedLevel: "off", + defaultActivation: () => "always", + contextTokens: 8192, + effectiveModelDirective: directives.rawModelDirective, + typing, + }); + + return { result, sessionEntry, sessionStore, typing }; } describe("mixed inline directives", () => { beforeEach(() => { vi.clearAllMocks(); + persistenceMocks.persist.mockImplementation(async ({ entry }) => ({ + status: "current", + entry: { ...entry }, + })); }); - it("emits directive ack while persisting inline reasoning in mixed messages", async () => { - const directives = parseInlineDirectives("please reply\n/reasoning on"); - const cfg = createConfig(); - const sessionEntry = createSessionEntry(); - const sessionStore = { "agent:main:dm:1": sessionEntry }; - - const fastLane = await applyInlineDirectivesFastLane({ - directives, - commandAuthorized: true, - senderIsOwner: false, - ctx: { Surface: "whatsapp" } as never, - cfg, - agentId: "main", - isGroup: false, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - messageProviderKey: "whatsapp", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - modelState: { - resolveDefaultThinkingLevel: async () => "off", - resolveThinkingCatalog: async () => [], - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - }, + it("commits mixed reasoning exactly once and emits one transition", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/reasoning on", + storePath: "/tmp/sessions.json", }); - expect(fastLane.directiveAck).toEqual({ - text: "⚙️ Reasoning visibility enabled.", + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: "⚙️ Reasoning visibility enabled." }, }); - - const persisted = await persistInlineDirectives({ - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey: "agent:main:dm:1", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - messageProvider: "whatsapp", - surface: "whatsapp", - gatewayClientScopes: [], - }); - expect(sessionEntry.reasoningLevel).toBe("on"); - expect(persisted.provider).toBe("anthropic"); - expect(persisted.model).toBe("claude-opus-4-6"); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + expect(enqueueSystemEvent).toHaveBeenCalledOnce(); }); - it("persists reasoning off and emits the disabled ack", async () => { - const directives = parseInlineDirectives("please reply\n/reasoning off"); - const cfg = createConfig(); - const sessionEntry = createSessionEntry({ reasoningLevel: "on" }); - const sessionStore = { "agent:main:discord:user": sessionEntry }; + it.each([ + { mode: "off", initial: "on", expectedAck: "Reasoning visibility disabled." }, + { mode: "stream", initial: undefined, expectedAck: "Reasoning stream enabled." }, + ])( + "persists reasoning $mode with a channel-neutral acknowledgement", + async ({ mode, initial, expectedAck }) => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: `please reply\n/reasoning ${mode}`, + sessionEntry: createSessionEntry({ reasoningLevel: initial }), + channel: "discord", + }); - const fastLane = await applyInlineDirectivesFastLane({ - directives, - commandAuthorized: true, - senderIsOwner: false, - ctx: { Surface: "discord" } as never, - cfg, - agentId: "main", - isGroup: false, - sessionEntry, - sessionStore, - sessionKey: "agent:main:discord:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - messageProviderKey: "discord", - defaultProvider: "openrouter", - defaultModel: "x-ai/grok-4.1-fast", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - provider: "openrouter", - model: "x-ai/grok-4.1-fast", - initialModelLabel: "openrouter/x-ai/grok-4.1-fast", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - modelState: { - resolveDefaultThinkingLevel: async () => "off", - resolveThinkingCatalog: async () => [], - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - }, - }); + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: `⚙️ ${expectedAck}` }, + }); + expect(sessionEntry.reasoningLevel).toBe(mode); + }, + ); - expect(fastLane.directiveAck).toEqual({ - text: "⚙️ Reasoning visibility disabled.", - }); - - await persistInlineDirectives({ - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey: "agent:main:discord:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "openrouter", - defaultModel: "x-ai/grok-4.1-fast", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - provider: "openrouter", - model: "x-ai/grok-4.1-fast", - initialModelLabel: "openrouter/x-ai/grok-4.1-fast", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - messageProvider: "discord", - surface: "discord", - gatewayClientScopes: [], - }); - - expect(sessionEntry.reasoningLevel).toBe("off"); - }); - - it("retargets queued thinking after a mixed-content model switch", async () => { - const directives = parseInlineDirectives("please reply /model openai/gpt-5.6-luna"); - const sessionEntry = createSessionEntry({ thinkingLevel: "ultra" }); - const sessionKey = "agent:main:dm:1"; + it("commits a model switch and retargets queued followups once", async () => { const cfg = { commands: { text: true }, agents: { - defaults: { - models: { - "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } }, - }, - }, + defaults: { models: { "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } } } }, }, - } as unknown as OpenClawConfig; - - await persistInlineDirectives({ - directives, - effectiveModelDirective: directives.rawModelDirective, + } as OpenClawConfig; + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply /model openai/gpt-5.6-luna", cfg, - sessionEntry, - sessionStore: { [sessionKey]: sessionEntry }, - sessionKey, - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "openai", - defaultModel: "gpt-5.6-sol", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(["openai/gpt-5.6-luna"]), - modelCatalog: [{ provider: "openai", id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }], + sessionEntry: createSessionEntry({ thinkingLevel: "ultra" }), + storePath: "/tmp/sessions.json", provider: "openai", model: "gpt-5.6-sol", - initialModelLabel: "openai/gpt-5.6-sol", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, + allowedModels: [{ provider: "openai", id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }], + senderIsOwner: true, }); + expect(result).toMatchObject({ kind: "continue", provider: "openai", model: "gpt-5.6-luna" }); expect(sessionEntry.thinkingLevel).toBe("max"); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + expect(triggerSessionPatchHook).toHaveBeenCalledOnce(); + expect(refreshQueuedFollowupSession).toHaveBeenCalledOnce(); + expect(persistStickyModelSelectionBestEffort).toHaveBeenCalledOnce(); + expect(enqueueSystemEvent).toHaveBeenCalledOnce(); + expect(enqueueSystemEvent).toHaveBeenCalledWith("Model switched to openai/gpt-5.6-luna.", { + sessionKey: "agent:main:dm:1", + contextKey: "model:openai/gpt-5.6-luna", + }); expect(refreshQueuedFollowupSession).toHaveBeenCalledWith( expect.objectContaining({ - key: sessionKey, + key: "agent:main:dm:1", nextProvider: "openai", nextModel: "gpt-5.6-luna", nextThinking: expect.objectContaining({ level: "max", agentRuntime: "codex" }), @@ -242,159 +240,217 @@ describe("mixed inline directives", () => { ); }); - it("emits a channel-neutral ack for reasoning stream", async () => { - const directives = parseInlineDirectives("please reply\n/reasoning stream"); - const cfg = createConfig(); - const sessionEntry = createSessionEntry(); - const sessionStore = { "agent:main:discord:user": sessionEntry }; - - const fastLane = await applyInlineDirectivesFastLane({ - directives, - commandAuthorized: true, - senderIsOwner: false, - ctx: { Surface: "discord" } as never, - cfg, - agentId: "main", - isGroup: false, - sessionEntry, - sessionStore, - sessionKey: "agent:main:discord:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - messageProviderKey: "discord", - defaultProvider: "openrouter", - defaultModel: "x-ai/grok-4.1-fast", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - provider: "openrouter", - model: "x-ai/grok-4.1-fast", - initialModelLabel: "openrouter/x-ai/grok-4.1-fast", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - modelState: { - resolveDefaultThinkingLevel: async () => "off", - resolveThinkingCatalog: async () => [], - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - }, + it("routes a mixed default reset to the actual default after clearing override fields", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply /model default", + provider: "openai", + model: "gpt-5.6-sol", + defaultProvider: "anthropic", + defaultModel: "claude-opus-4-6", + sessionEntry: createSessionEntry({ + providerOverride: "openai", + modelOverride: "gpt-5.6-sol", + modelOverrideSource: "user", + }), + allowedModels: [ + { + provider: "anthropic", + id: "claude-opus-4-6", + name: "Claude Opus", + contextTokens: 90_000, + }, + ], }); - expect(fastLane.directiveAck).toEqual({ - text: "⚙️ Reasoning stream enabled.", + expect(result).toMatchObject({ + kind: "continue", + provider: "anthropic", + model: "claude-opus-4-6", + contextTokens: 90_000, }); + expect(sessionEntry.providerOverride).toBeUndefined(); + expect(sessionEntry.modelOverride).toBeUndefined(); }); - it("persists mixed exec defaults for authorized external senders with empty gateway scopes", async () => { - const directives = parseInlineDirectives( - "please reply\n/exec host=node security=allowlist ask=always node=worker-1", - ); - const cfg = createConfig(); - const sessionEntry = createSessionEntry(); - const sessionStore = { "agent:main:telegram:user": sessionEntry }; - - const fastLane = await applyInlineDirectivesFastLane({ - directives, - commandAuthorized: true, - senderIsOwner: false, - ctx: { Provider: "telegram", GatewayClientScopes: [] } as never, - cfg, - agentId: "main", - isGroup: false, - sessionEntry, - sessionStore, - sessionKey: "agent:main:telegram:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - elevatedFailures: [], - messageProviderKey: "telegram", - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - modelState: { - resolveDefaultThinkingLevel: async () => "off", - resolveThinkingCatalog: async () => [], - allowedModelKeys: new Set(), - allowedModelCatalog: [], - resetModelOverride: false, - }, + it("preserves persisted and per-message queue options in one mixed transaction", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/queue collect debounce:1500 cap:4 drop:old", + storePath: "/tmp/sessions.json", }); - expect(fastLane.directiveAck?.text).toContain("Exec defaults set"); - expect(fastLane.directiveAck?.text).not.toContain("operator.admin"); + expect(result).toMatchObject({ + kind: "continue", + perMessageQueueMode: "collect", + perMessageQueueOptions: { debounceMs: 1500, cap: 4, dropPolicy: "old" }, + }); + expect(sessionEntry).toMatchObject({ + queueMode: "collect", + queueDebounceMs: 1500, + queueCap: 4, + }); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + }); - await persistInlineDirectives({ - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey: "agent:main:telegram:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - messageProvider: "telegram", + it("persists fast-mode and external exec defaults for authorized mixed messages", async () => { + const fast = await applyMixedDirectives({ body: "please reply\n/fast on" }); + expect(fast.sessionEntry.fastMode).toBe(true); + + const exec = await applyMixedDirectives({ + body: "please reply\n/exec host=node security=allowlist ask=always node=worker-1", gatewayClientScopes: [], - commandAuthorized: true, }); - - expect(sessionEntry.execHost).toBe("node"); - expect(sessionEntry.execSecurity).toBe("allowlist"); - expect(sessionEntry.execAsk).toBe("always"); - expect(sessionEntry.execNode).toBe("worker-1"); + expect(exec.result).toMatchObject({ + kind: "continue", + directiveAck: { text: expect.stringContaining("Exec defaults set") }, + }); + expect(exec.sessionEntry).toMatchObject({ + execHost: "node", + execSecurity: "allowlist", + execAsk: "always", + execNode: "worker-1", + }); }); it("does not persist trace directives for unauthorized mixed messages", async () => { - const directives = parseInlineDirectives("please reply\n/trace raw"); - const cfg = createConfig(); - const sessionEntry = createSessionEntry({ traceLevel: "off" as const }); - const sessionStore = { "agent:main:telegram:user": sessionEntry }; - - await persistInlineDirectives({ - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey: "agent:main:telegram:user", - storePath: undefined, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: { byAlias: new Map(), byKey: new Map() }, - allowedModelKeys: new Set(), - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => label, - agentCfg: cfg.agents?.defaults, - messageProvider: "telegram", - surface: "telegram", + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/trace raw", + sessionEntry: createSessionEntry({ traceLevel: "off" }), gatewayClientScopes: [], - senderIsOwner: false, }); + expect(result).toMatchObject({ kind: "continue" }); expect(sessionEntry.traceLevel).toBe("off"); + expect(persistenceMocks.persist).not.toHaveBeenCalled(); + }); + + it.each([ + { + ignored: "/trace raw", + expectedAck: "/trace is restricted to owners", + }, + { + ignored: "/verbose nonsense", + expectedAck: "Current verbose level:", + }, + { + ignored: "/fast status", + expectedAck: "Current fast mode:", + }, + ])( + "applies valid sibling settings despite an ignored $ignored directive", + async ({ ignored, expectedAck }) => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: `please reply\n${ignored}\n/reasoning on`, + storePath: "/tmp/sessions.json", + gatewayClientScopes: [], + }); + + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: expect.stringContaining(expectedAck) }, + }); + expect(sessionEntry.reasoningLevel).toBe("on"); + expect(sessionEntry.traceLevel).toBeUndefined(); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + }, + ); + + it("keeps authorized exec fields when a sibling exec option is invalid", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/exec host=node security=bogus\n/reasoning on", + storePath: "/tmp/sessions.json", + }); + + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: expect.stringContaining('Unrecognized exec security "bogus"') }, + }); + expect(sessionEntry).toMatchObject({ execHost: "node", reasoningLevel: "on" }); + expect(sessionEntry.execSecurity).toBeUndefined(); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + }); + + it("normalizes nested informational and unauthorized siblings into one commit", async () => { + const { result, sessionEntry } = await applyMixedDirectives({ + body: "please reply\n/trace raw\n/verbose nonsense\n/reasoning on", + storePath: "/tmp/sessions.json", + gatewayClientScopes: [], + }); + + expect(result).toMatchObject({ + kind: "continue", + directiveAck: { text: expect.stringContaining("/trace is restricted to owners") }, + }); + expect(sessionEntry.reasoningLevel).toBe("on"); + expect(sessionEntry.traceLevel).toBeUndefined(); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + }); + + it("does not announce unchanged elevated mode as a transition", async () => { + const { result } = await applyMixedDirectives({ + body: "please reply\n/elevated full", + sessionEntry: createSessionEntry({ elevatedLevel: "full" }), + storePath: "/tmp/sessions.json", + }); + + expect(result).toMatchObject({ kind: "continue" }); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("adopts an authoritative model lock and emits no losing side effects", async () => { + const sessionEntry = createSessionEntry({ + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "user", + }); + const lockedEntry = { ...sessionEntry, updatedAt: 2, modelSelectionLocked: true }; + persistenceMocks.persist.mockResolvedValueOnce({ + status: "model-selection-locked", + entry: lockedEntry, + }); + + const { result, sessionStore } = await applyMixedDirectives({ + body: "please reply /model openai/gpt-5.6-luna", + sessionEntry, + storePath: "/tmp/sessions.json", + allowedModels: [{ provider: "openai", id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }], + senderIsOwner: true, + }); + + expect(result).toEqual({ kind: "reply", reply: { text: MODEL_SELECTION_LOCKED_MESSAGE } }); + expect(persistenceMocks.persist).toHaveBeenCalledWith( + expect.objectContaining({ requireModelSelectionUnlocked: true }), + ); + expect(sessionEntry).toEqual(lockedEntry); + expect(sessionStore["agent:main:dm:1"]).toEqual(lockedEntry); + expect(triggerSessionPatchHook).not.toHaveBeenCalled(); + expect(refreshQueuedFollowupSession).not.toHaveBeenCalled(); + expect(persistStickyModelSelectionBestEffort).not.toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + }); + + it("reports a locked valid model instead of an ignored unauthorized sibling", async () => { + const sessionEntry = createSessionEntry(); + const lockedEntry = { ...sessionEntry, updatedAt: 2, modelSelectionLocked: true }; + persistenceMocks.persist.mockResolvedValueOnce({ + status: "model-selection-locked", + entry: lockedEntry, + }); + + const { result } = await applyMixedDirectives({ + body: "please reply\n/trace raw\n/model openai/gpt-5.6-luna", + sessionEntry, + storePath: "/tmp/sessions.json", + allowedModels: [{ provider: "openai", id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }], + gatewayClientScopes: [], + }); + + expect(result).toEqual({ kind: "reply", reply: { text: MODEL_SELECTION_LOCKED_MESSAGE } }); + expect(sessionEntry).toEqual(lockedEntry); + expect(persistenceMocks.persist).toHaveBeenCalledOnce(); + expect(triggerSessionPatchHook).not.toHaveBeenCalled(); + expect(refreshQueuedFollowupSession).not.toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); }); }); diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts index a49220443ca3..c62eb0843cfc 100644 --- a/src/auto-reply/reply/directive-handling.model.test.ts +++ b/src/auto-reply/reply/directive-handling.model.test.ts @@ -317,7 +317,8 @@ let createModelVisibilityPolicy: typeof import("../../agents/model-visibility-po let buildModelAliasIndex: typeof import("../../agents/model-selection.js").buildModelAliasIndex; let resolveModelSelectionFromDirective: typeof import("./directive-handling.model-selection.js").resolveModelSelectionFromDirective; let parseInlineDirectives: typeof import("./directive-handling.parse.js").parseInlineDirectives; -let persistInlineDirectives: typeof import("./directive-handling.persist.js").persistInlineDirectives; +let applyInlineDirectiveOverrides: typeof import("./get-reply-directives-apply.js").applyInlineDirectiveOverrides; +let createFastTestModelSelectionState: typeof import("./model-selection.js").createFastTestModelSelectionState; beforeAll(async () => { ({ testing: cliBackendsTesting } = await import("../../agents/cli-backends.test-support.js")); @@ -328,7 +329,8 @@ beforeAll(async () => { ({ resolveModelSelectionFromDirective } = await import("./directive-handling.model-selection.js")); ({ parseInlineDirectives } = await import("./directive-handling.parse.js")); - ({ persistInlineDirectives } = await import("./directive-handling.persist.js")); + ({ applyInlineDirectiveOverrides } = await import("./get-reply-directives-apply.js")); + ({ createFastTestModelSelectionState } = await import("./model-selection.js")); }); const queueMocks = vi.hoisted(() => ({ refreshQueuedFollowupSession: vi.fn(), @@ -570,67 +572,136 @@ async function persistModelDirectiveForTest(params: { if (params.profiles) { setAuthProfiles(params.profiles); } - const directives = parseInlineDirectives(params.command); + const originalDirectives = parseInlineDirectives(params.command); + const commandBody = originalDirectives.cleaned.trim() + ? params.command + : `${params.command} continue with the request`; + const directives = parseInlineDirectives(commandBody); const cfg = params.cfg ?? baseConfig(); const sessionEntry = params.sessionEntry ?? createSessionEntry(); - const persisted = await persistInlineDirectives({ - directives, - effectiveModelDirective: directives.rawModelDirective, + const provider = params.provider ?? "anthropic"; + const model = params.model ?? "claude-opus-4-6"; + const sessionKey = "agent:main:dm:1"; + const modelState = createFastTestModelSelectionState({ + agentCfg: cfg.agents?.defaults, + provider, + model, + }); + modelState.allowedModelKeys = new Set(params.allowedModelKeys); + modelState.allowedModelCatalog = params.allowedModelCatalog ?? []; + modelState.resolveThinkingCatalog = async () => params.allowedModelCatalog; + const result = await applyInlineDirectiveOverrides({ + ctx: { Body: commandBody, Provider: "telegram", Surface: "telegram" }, cfg, + agentId: "main", agentDir: TEST_AGENT_DIR, + workspaceDir: "/tmp/workspace", + agentCfg: cfg.agents?.defaults ?? {}, sessionEntry, - sessionStore: { "agent:main:dm:1": sessionEntry }, - sessionKey: "agent:main:dm:1", - storePath: undefined, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + sessionScope: undefined, + isGroup: false, + allowTextCommands: true, + command: { + surface: "telegram", + channel: "telegram", + ownerList: [], + senderIsOwner: params.canPersistStickyModelSelection ?? true, + isAuthorizedSender: true, + rawBodyNormalized: commandBody, + commandBodyNormalized: commandBody, + }, + directives, + messageProviderKey: "telegram", elevatedEnabled: false, elevatedAllowed: false, + elevatedFailures: [], defaultProvider: "anthropic", defaultModel: "claude-opus-4-6", aliasIndex: params.aliasIndex ?? baseAliasIndex(), - allowedModelKeys: new Set(params.allowedModelKeys), - modelCatalog: params.allowedModelCatalog, - provider: params.provider ?? "anthropic", - model: params.model ?? "claude-opus-4-6", - initialModelLabel: - params.initialModelLabel ?? - `${params.provider ?? "anthropic"}/${params.model ?? "claude-opus-4-6"}`, + provider, + model, + modelState, + initialModelLabel: params.initialModelLabel ?? `${provider}/${model}`, formatModelSwitchEvent: (label) => label, - canPersistStickyModelSelection: params.canPersistStickyModelSelection ?? true, - agentCfg: cfg.agents?.defaults, + resolvedElevatedLevel: "off", + defaultActivation: () => "always", + contextTokens: 8192, + effectiveModelDirective: directives.rawModelDirective, + typing: { + onReplyStart: async () => {}, + startTypingLoop: async () => {}, + startTypingOnText: async () => {}, + refreshTypingTtl: () => {}, + isActive: () => false, + markRunComplete: () => {}, + markDispatchIdle: () => {}, + cleanup: () => {}, + }, }); + const persisted = + result.kind === "continue" + ? { + provider: result.provider, + model: result.model, + contextTokens: result.contextTokens, + directiveAck: result.directiveAck, + errorText: undefined, + } + : { + provider, + model, + contextTokens: 8192, + directiveAck: undefined, + errorText: Array.isArray(result.reply) ? result.reply[0]?.text : result.reply?.text, + }; return { persisted, sessionEntry }; } -type PersistInlineDirectivesParams = Parameters[0]; +type HandleDirectiveParams = Parameters[0]; -async function persistInternalOperatorWriteDirective( - command: string, - overrides: Partial = {}, -) { +function createDirectiveHandlingParams( + overrides: Partial, +): HandleDirectiveParams { + const sessionKey = overrides.sessionKey ?? "agent:main:main"; const sessionEntry = overrides.sessionEntry ?? createSessionEntry(); - const sessionStore = overrides.sessionStore ?? { "agent:main:main": sessionEntry }; - await persistInlineDirectives({ - directives: parseInlineDirectives(command), + return { cfg: baseConfig(), + directives: parseInlineDirectives(""), sessionEntry, - sessionStore, - sessionKey: "agent:main:main", - storePath: "/tmp/sessions.json", + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, elevatedEnabled: true, elevatedAllowed: true, defaultProvider: "anthropic", defaultModel: "claude-opus-4-6", aliasIndex: baseAliasIndex(), allowedModelKeys: new Set(["anthropic/claude-opus-4-6", "openai/gpt-4o"]), + allowedModelCatalog: [], + resetModelOverride: false, provider: "anthropic", model: "claude-opus-4-6", initialModelLabel: "anthropic/claude-opus-4-6", formatModelSwitchEvent: (label) => `Switched to ${label}`, - agentCfg: undefined, - surface: "webchat", - gatewayClientScopes: ["operator.write"], ...overrides, - }); + }; +} + +async function persistInternalOperatorWriteDirective( + command: string, + overrides: Partial = {}, +) { + const sessionEntry = overrides.sessionEntry ?? createSessionEntry(); + await handleDirectiveOnly( + createDirectiveHandlingParams({ + directives: parseInlineDirectives(command), + sessionEntry, + surface: "webchat", + gatewayClientScopes: ["operator.write"], + ...overrides, + }), + ); return sessionEntry; } @@ -1549,7 +1620,7 @@ describe("/model chat UX", () => { expect(sessionEntry.providerOverride).toBe(provider); expect(sessionEntry.modelOverride).toBe(model); expect(sessionEntry.agentRuntimeOverride).toBe("codex"); - expect(persisted.runtimeChange).toEqual({ kind: "set", runtime: "codex" }); + expect(persisted.directiveAck?.text).toContain("Runtime set to codex for this session."); }); it("normalizes legacy Codex app-server runtime overrides during persistence", async () => { @@ -1650,7 +1721,7 @@ describe("/model chat UX", () => { }); expect(sessionEntry.agentRuntimeOverride).toBeUndefined(); - expect(persisted.runtimeChange).toEqual({ kind: "clear" }); + expect(persisted.directiveAck?.text).toContain("Runtime reset to configured policy."); }); it("rejects model/runtime transactions that target an unsupported runtime", async () => { @@ -2004,6 +2075,44 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { expect(sessionEntry).toEqual(initialSessionEntry); }); + it("rechecks a newly persisted model lock before committing directive changes", async () => { + const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-directive-lock-")); + const storePath = path.join(tempRoot, "sessions.json"); + const sessionEntry = createSessionEntry({ + providerOverride: "anthropic", + modelOverride: "claude-opus-4-6", + modelOverrideSource: "user", + }); + const lockedEntry: SessionEntry = { + ...sessionEntry, + updatedAt: sessionEntry.updatedAt + 1, + modelSelectionLocked: true, + }; + await replaceSessionEntry({ sessionKey, storePath }, lockedEntry); + const sessionStore = { [sessionKey]: sessionEntry }; + + try { + const result = await handleDirectiveOnly( + createHandleParams({ + directives: parseInlineDirectives("/model openai/gpt-4o"), + sessionEntry, + sessionStore, + storePath, + }), + ); + + expect(result?.text).toBe(MODEL_SELECTION_LOCKED_MESSAGE); + expect(sessionEntry).toEqual(lockedEntry); + expect(sessionStore[sessionKey]).toEqual(lockedEntry); + expect(loadSessionEntry({ sessionKey, storePath })).toEqual(lockedEntry); + expect(queueMocks.refreshQueuedFollowupSession).not.toHaveBeenCalled(); + expect(stickyModelMock.persistBestEffort).not.toHaveBeenCalled(); + expect(enqueueSystemEvent).not.toHaveBeenCalled(); + } finally { + fs.rmSync(tempRoot, { recursive: true, force: true }); + } + }); + it("persists /model only on the targeted session entry", async () => { const targetEntry = createSessionEntry(); const otherEntry = createSessionEntry(); @@ -2065,12 +2174,9 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { }); expect(sessionEntry.thinkingLevel).toBe("medium"); - expect(persisted.thinkingRemap).toEqual({ - from: "adaptive", - to: "medium", - provider: "openai", - model: "gpt-4o", - }); + expect(persisted.directiveAck?.text).toContain( + "Thinking level set to medium (adaptive not supported for openai/gpt-4o).", + ); }); it("announces the model change before the thinking remap in the ack", async () => { @@ -2185,7 +2291,9 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { }; await replaceSessionEntry({ sessionKey, storePath }, concurrentEntry); const sessionStore = { [sessionKey]: sessionEntry }; - const persistenceState = { sessionChangesApplied: true }; + const persistenceState: NonNullable = { + outcome: { kind: "pending", provider: "anthropic", model: "claude-opus-4-6" }, + }; try { const result = await handleDirectiveOnly( @@ -2199,7 +2307,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { ); expect(result?.text).toContain("Model change was not applied"); - expect(persistenceState.sessionChangesApplied).toBe(false); + expect(persistenceState.outcome).toMatchObject({ kind: "rejected" }); expect(queueMocks.refreshQueuedFollowupSession).not.toHaveBeenCalled(); expect(enqueueSystemEvent).not.toHaveBeenCalledWith( expect.stringContaining("openai/gpt-4o"), @@ -2837,7 +2945,7 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => { }); }); -describe("persistInlineDirectives session directive persistence policy", () => { +describe("canonical session directive persistence policy", () => { it("checks an explicit same-value model selection against persisted state", async () => { const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-inline-model-race-")); const storePath = path.join(tempRoot, "sessions.json"); @@ -2856,31 +2964,22 @@ describe("persistInlineDirectives session directive persistence policy", () => { const directives = parseInlineDirectives("hello /model openai/gpt-4o"); try { - const result = await persistInlineDirectives({ - directives, - effectiveModelDirective: directives.rawModelDirective, - cfg: baseConfig(), - agentDir: TEST_AGENT_DIR, - sessionEntry, - sessionStore: { [sessionKey]: sessionEntry }, - sessionKey, - storePath, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: baseAliasIndex(), - allowedModelKeys: new Set(["openai/gpt-4o"]), - modelCatalog: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }], - provider: "openai", - model: "gpt-4o", - initialModelLabel: "openai/gpt-4o", - formatModelSwitchEvent: (label) => `Switched to ${label}`, - agentCfg: undefined, - }); + const result = await handleDirectiveOnly( + createDirectiveHandlingParams({ + directives, + sessionEntry, + sessionStore: { [sessionKey]: sessionEntry }, + sessionKey, + storePath, + allowedModelKeys: new Set(["openai/gpt-4o"]), + allowedModelCatalog: [{ provider: "openai", id: "gpt-4o", name: "GPT-4o" }], + provider: "openai", + model: "gpt-4o", + initialModelLabel: "openai/gpt-4o", + }), + ); - expect(result.sessionChangesApplied).toBe(false); - expect(result).toMatchObject({ provider: "openai", model: "gpt-5.5" }); + expect(result?.text).toContain("Model change was not applied"); expect(sessionEntry).toMatchObject({ providerOverride: "openai", modelOverride: "gpt-5.5", @@ -2914,35 +3013,22 @@ describe("persistInlineDirectives session directive persistence policy", () => { }); try { - const result = await persistInlineDirectives({ - directives, - effectiveModelDirective: directives.rawModelDirective, - cfg: baseConfig(), - agentDir: TEST_AGENT_DIR, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled: false, - elevatedAllowed: false, - defaultProvider: "anthropic", - defaultModel: "claude-opus-4-6", - aliasIndex: baseAliasIndex(), - allowedModelKeys: new Set(["anthropic/claude-opus-4-6", "openai/gpt-4o"]), - modelCatalog: [ - { provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus 4.5" }, - { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, - ], - provider: "anthropic", - model: "claude-opus-4-6", - initialModelLabel: "anthropic/claude-opus-4-6", - formatModelSwitchEvent: (label) => `Switched to ${label}`, - canPersistStickyModelSelection: true, - agentCfg: undefined, - }); + const result = await handleDirectiveOnly( + createDirectiveHandlingParams({ + directives, + sessionEntry, + sessionStore, + sessionKey, + storePath, + allowedModelCatalog: [ + { provider: "anthropic", id: "claude-opus-4-6", name: "Claude Opus 4.5" }, + { provider: "openai", id: "gpt-4o", name: "GPT-4o" }, + ], + canPersistStickyModelSelection: true, + }), + ); - expect(result).toMatchObject({ provider: "openai", model: "gpt-5.5" }); - expect(result.sessionChangesApplied).toBe(false); + expect(result?.text).toContain("Model change was not applied"); expect(enqueueSystemEvent).not.toHaveBeenCalledWith( expect.stringContaining("openai/gpt-4o"), expect.anything(), diff --git a/src/auto-reply/reply/directive-handling.params.ts b/src/auto-reply/reply/directive-handling.params.ts index 4aafee665696..83e7d937226c 100644 --- a/src/auto-reply/reply/directive-handling.params.ts +++ b/src/auto-reply/reply/directive-handling.params.ts @@ -1,5 +1,5 @@ import type { FastMode } from "@openclaw/normalization-core/string-coerce"; -/** Parameter contracts shared by directive-only and fast-lane directive handlers. */ +/** Parameter contracts for the canonical directive transaction handler. */ import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; import type { ModelAliasIndex } from "../../agents/model-selection.js"; import type { SessionEntry } from "../../config/sessions.js"; @@ -51,27 +51,10 @@ export type HandleDirectiveOnlyParams = HandleDirectiveOnlyCoreParams & { gatewayClientScopes?: string[]; commandAuthorized?: boolean; senderIsOwner?: boolean; - /** Internal handoff for mixed inline directives to avoid retrying rejected writes. */ - persistenceState?: { sessionChangesApplied: boolean }; -}; - -/** Inputs for applying inline directives before the full reply run is prepared. */ -export type ApplyInlineDirectivesFastLaneParams = HandleDirectiveOnlyCoreParams & { - commandAuthorized: boolean; - senderIsOwner: boolean; - ctx: MsgContext; - workspaceDir?: string; - agentId?: string; - isGroup: boolean; - agentCfg?: NonNullable["defaults"]; - modelState: { - resolveDefaultThinkingLevel: () => Promise; - resolveThinkingCatalog: () => Promise; - allowedModelKeys: Set; - allowedModelCatalog: Awaited< - ReturnType - >; - policyAliasIndex?: ModelAliasIndex; - resetModelOverride: boolean; + /** Mixed messages consume the transaction outcome without repeating persistence. */ + persistenceState?: { + outcome: + | { kind: "pending" | "applied"; provider: string; model: string } + | { kind: "rejected"; errorText: string }; }; }; diff --git a/src/auto-reply/reply/directive-handling.persist.runtime.ts b/src/auto-reply/reply/directive-handling.persist.runtime.ts index 394957a28bad..6d78a0846469 100644 --- a/src/auto-reply/reply/directive-handling.persist.runtime.ts +++ b/src/auto-reply/reply/directive-handling.persist.runtime.ts @@ -1,4 +1,2 @@ -/** Runtime facade for persisting inline directive state after parsing. */ -export { persistInlineDirectives } from "./directive-handling.persist.js"; -// This facade is the lazy boundary used by get-reply's directive-only fast path. +/** Lazy runtime boundary for the shipped model-selection service. */ export { applySessionModelSelection } from "../../model-picker/apply-session-model-selection.js"; diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts deleted file mode 100644 index 953e7ffaaf58..000000000000 --- a/src/auto-reply/reply/directive-handling.persist.ts +++ /dev/null @@ -1,398 +0,0 @@ -// Persists directive-derived session preferences such as model and auth choices. -import { - resolveAgentDir, - resolveDefaultAgentId, - resolveSessionAgentId, -} from "../../agents/agent-scope.js"; -import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; -import type { ModelCatalogEntry } from "../../agents/model-catalog.js"; -import { modelKey, type ModelAliasIndex } from "../../agents/model-selection.js"; -import { resolveContextConfigProviderForRuntime } from "../../agents/openai-routing.js"; -import { persistStickyModelSelectionBestEffort } from "../../agents/sticky-model-selection.js"; -import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js"; -import type { SessionEntry } from "../../config/sessions/types.js"; -import type { OpenClawConfig } from "../../config/types.openclaw.js"; -import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js"; -import { enqueueSystemEvent } from "../../infra/system-events.js"; -import { applySessionModelSelectionToEntry } from "../../model-picker/apply-session-model-selection.js"; -import { - formatThinkingLevels, - isThinkingLevelSupported, - resolveSupportedThinkingLevel, -} from "../thinking.js"; -import { - applyModelRuntimeDirective, - resolveModelRuntimeDirective, -} from "./directive-handling.model-runtime.js"; -import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; -import type { InlineDirectives } from "./directive-handling.parse.js"; -import { - applySessionDirectiveFields, - canPersistSessionDirectiveDefaults, - enqueueModeSwitchEvents, - persistSessionDirectiveSnapshot, - resolveDirectiveTouchedSessionFields, -} from "./directive-handling.shared.js"; -import type { ThinkLevel } from "./directives.js"; -import { resolveContextTokens } from "./model-selection.js"; -import { refreshQueuedFollowupSession } from "./queue.js"; - -type PersistedThinkingLevelRemap = { - from: ThinkLevel; - to: ThinkLevel; - provider: string; - model: string; -}; - -export async function persistInlineDirectives(params: { - directives: InlineDirectives; - effectiveModelDirective?: string; - cfg: OpenClawConfig; - agentDir?: string; - sessionEntry?: SessionEntry; - sessionStore?: Record; - sessionKey?: string; - storePath?: string; - elevatedEnabled: boolean; - elevatedAllowed: boolean; - defaultProvider: string; - defaultModel: string; - aliasIndex: ModelAliasIndex; - allowedModelKeys: Set; - provider: string; - model: string; - initialModelLabel: string; - formatModelSwitchEvent: (label: string, alias?: string) => string; - canPersistStickyModelSelection?: boolean; - agentCfg: NonNullable["defaults"] | undefined; - messageProvider?: string; - surface?: string; - gatewayClientScopes?: string[]; - commandAuthorized?: boolean; - senderIsOwner?: boolean; - markLiveSwitchPending?: boolean; - modelCatalog?: ModelCatalogEntry[]; - thinkingCatalog?: ModelCatalogEntry[]; -}): Promise<{ - provider: string; - model: string; - contextTokens: number; - sessionChangesApplied: boolean; - thinkingRemap?: PersistedThinkingLevelRemap; - errorText?: string; - runtimeChange?: { kind: "clear" } | { kind: "set"; runtime: string }; -}> { - const { - directives, - cfg, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys, - initialModelLabel, - formatModelSwitchEvent, - agentCfg, - } = params; - let { provider, model } = params; - let thinkingRemap: PersistedThinkingLevelRemap | undefined; - let sessionChangesApplied = true; - const allowPrivilegedPersistence = canPersistSessionDirectiveDefaults({ - messageProvider: params.messageProvider, - surface: params.surface, - gatewayClientScopes: params.gatewayClientScopes, - commandAuthorized: params.commandAuthorized, - senderIsOwner: params.senderIsOwner, - }); - const touchedSessionFields = resolveDirectiveTouchedSessionFields({ - directives, - allowPrivilegedPersistence, - }); - const thinkingCatalog = - params.thinkingCatalog && params.thinkingCatalog.length > 0 - ? params.thinkingCatalog - : undefined; - const delegatedTraceAllowed = (params.gatewayClientScopes ?? []).includes("operator.admin"); - const activeAgentId = sessionKey - ? resolveSessionAgentId({ sessionKey, config: cfg }) - : resolveDefaultAgentId(cfg); - const agentDir = resolveAgentDir(cfg, activeAgentId) ?? params.agentDir; - const modelDirective = - directives.hasModelDirective && params.effectiveModelDirective - ? params.effectiveModelDirective - : undefined; - const modelResolution = modelDirective - ? resolveModelSelectionFromDirective({ - directives: { - ...directives, - hasModelDirective: true, - rawModelDirective: modelDirective, - }, - cfg, - agentDir, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys, - allowedModelCatalog: params.modelCatalog ?? [], - provider, - agentId: activeAgentId, - }) - : undefined; - const modelRuntimeResolution = modelResolution?.modelSelection - ? resolveModelRuntimeDirective({ - rawRuntime: directives.rawModelRuntime, - provider: modelResolution.modelSelection.provider, - cfg, - sessionEntry, - }) - : ({ kind: "unchanged" } as const); - let thinkingErrorText: string | undefined; - if (directives.hasThinkDirective && directives.thinkLevel) { - const resolvedProvider = modelResolution?.modelSelection?.provider ?? provider; - const resolvedModel = modelResolution?.modelSelection?.model ?? model; - const prospectiveSessionEntry = { ...sessionEntry }; - applyModelRuntimeDirective(prospectiveSessionEntry, modelRuntimeResolution); - const prospectiveThinkingRuntime = resolveEffectiveAgentRuntime({ - cfg, - provider: resolvedProvider, - modelId: resolvedModel, - agentId: activeAgentId, - sessionKey, - sessionEntry: prospectiveSessionEntry, - }); - if ( - !isThinkingLevelSupported({ - provider: resolvedProvider, - model: resolvedModel, - level: directives.thinkLevel, - catalog: thinkingCatalog, - agentRuntime: prospectiveThinkingRuntime, - }) - ) { - thinkingErrorText = `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, prospectiveThinkingRuntime)}.`; - } - } - const errorText = - modelResolution?.errorText ?? - (modelRuntimeResolution.kind === "invalid" ? modelRuntimeResolution.errorText : undefined) ?? - thinkingErrorText; - let modelRuntimeApplied = false; - - if (!errorText && sessionEntry && sessionStore && sessionKey) { - const initialSessionEntry = { ...sessionEntry }; - const appliedSessionEntry = sessionEntry; - const elevatedChanged = - directives.hasElevatedDirective && - directives.elevatedLevel !== undefined && - elevatedEnabled && - elevatedAllowed; - const reasoningChanged = - directives.hasReasoningDirective && directives.reasoningLevel !== undefined; - let updated = applySessionDirectiveFields({ - directives, - sessionEntry, - allowPrivilegedPersistence, - allowTracePersistence: params.senderIsOwner === true || delegatedTraceAllowed, - allowElevatedPersistence: elevatedEnabled && elevatedAllowed, - persistDirectiveOnlyFields: false, - }); - - let modelUpdated = false; - let modelApplied = true; - let modelSwitchEvent: { alias?: string; label: string } | undefined; - if (modelDirective && modelResolution?.modelSelection) { - if (modelRuntimeResolution.kind === "invalid") { - throw new Error("invalid model runtime reached persistence"); - } - const appliedSelection = applySessionModelSelectionToEntry({ - entry: sessionEntry, - request: { - ...modelResolution.modelSelection, - profileOverride: modelResolution.profileOverride, - runtime: modelRuntimeResolution, - }, - runtime: modelRuntimeResolution, - markLiveSwitchPending: params.markLiveSwitchPending, - }); - modelUpdated = appliedSelection.changed; - provider = modelResolution.modelSelection.provider; - model = modelResolution.modelSelection.model; - const thinkingRuntime = resolveEffectiveAgentRuntime({ - cfg, - provider, - modelId: model, - agentId: activeAgentId, - sessionKey, - sessionEntry, - }); - const currentThinkingLevel = sessionEntry.thinkingLevel as ThinkLevel | undefined; - if ( - currentThinkingLevel && - !directives.hasThinkDirective && - !isThinkingLevelSupported({ - provider, - model, - level: currentThinkingLevel, - catalog: thinkingCatalog, - agentRuntime: thinkingRuntime, - }) - ) { - const remappedThinkingLevel = resolveSupportedThinkingLevel({ - provider, - model, - level: currentThinkingLevel, - catalog: thinkingCatalog, - agentRuntime: thinkingRuntime, - }); - if (remappedThinkingLevel !== currentThinkingLevel) { - sessionEntry.thinkingLevel = remappedThinkingLevel; - thinkingRemap = { - from: currentThinkingLevel, - to: remappedThinkingLevel, - provider, - model, - }; - } - } - const nextLabel = `${provider}/${model}`; - if (nextLabel !== initialModelLabel) { - modelSwitchEvent = { - label: nextLabel, - ...(modelResolution.modelSelection.alias - ? { alias: modelResolution.modelSelection.alias } - : {}), - }; - } - // Explicit model selections must still perform the atomic persisted - // winner check when their value matches the local snapshot. - updated = true; - } - if (updated) { - sessionEntry.updatedAt = Date.now(); - sessionStore[sessionKey] = sessionEntry; - if (storePath) { - const persistence = await persistSessionDirectiveSnapshot({ - storePath, - sessionKey, - initialEntry: initialSessionEntry, - sessionEntry, - sessionStore, - hasModelSelection: Boolean(modelDirective), - reassertLiveModelSwitchPending: - modelUpdated && - params.markLiveSwitchPending === true && - sessionEntry.liveModelSwitchPending === true, - touchedFields: touchedSessionFields, - }); - sessionChangesApplied = persistence.sessionChangesApplied; - modelApplied = persistence.modelSelectionApplied; - } - if (modelDirective && !modelApplied) { - sessionChangesApplied = false; - const persistedEntry = sessionStore[sessionKey]; - provider = persistedEntry?.providerOverride?.trim() || defaultProvider; - model = persistedEntry?.modelOverride?.trim() || defaultModel; - thinkingRemap = undefined; - } - if ( - modelDirective && - modelResolution?.modelSelection && - modelApplied && - !modelResolution.modelSelection.isDefault && - params.canPersistStickyModelSelection === true - ) { - persistStickyModelSelectionBestEffort({ - agentId: activeAgentId, - model: `${provider}/${model}`, - }); - } - if (modelDirective && modelUpdated && modelApplied) { - triggerSessionPatchHook({ - cfg, - sessionEntry: appliedSessionEntry, - sessionKey, - patch: { key: sessionKey, model: modelDirective }, - }); - refreshQueuedFollowupSession({ - key: sessionKey, - nextProvider: provider, - nextModel: model, - nextRouteResolution: "resolved", - nextModelOverrideSource: "user", - nextAuthProfileId: appliedSessionEntry.authProfileOverride, - nextAuthProfileIdSource: appliedSessionEntry.authProfileOverrideSource, - nextThinking: { - level: appliedSessionEntry.thinkingLevel, - catalog: thinkingCatalog, - agentRuntime: resolveEffectiveAgentRuntime({ - cfg, - provider, - modelId: model, - agentId: activeAgentId, - sessionKey, - sessionEntry: appliedSessionEntry, - }), - }, - }); - } - if (sessionChangesApplied) { - enqueueModeSwitchEvents({ - enqueueSystemEvent, - sessionEntry: appliedSessionEntry, - sessionKey, - elevatedChanged, - reasoningChanged, - }); - } - } - modelRuntimeApplied = - modelApplied && - (modelRuntimeResolution.kind === "clear" || modelRuntimeResolution.kind === "set"); - if (modelSwitchEvent && modelApplied) { - enqueueSystemEvent(formatModelSwitchEvent(modelSwitchEvent.label, modelSwitchEvent.alias), { - sessionKey, - contextKey: `model:${modelSwitchEvent.label}`, - }); - } - } - - const selectedCatalogEntry = params.modelCatalog?.find( - (entry) => modelKey(entry.provider, entry.id) === modelKey(provider, model), - ); - return { - provider, - model, - thinkingRemap, - errorText, - runtimeChange: - modelRuntimeApplied && - (modelRuntimeResolution.kind === "clear" || modelRuntimeResolution.kind === "set") - ? modelRuntimeResolution - : undefined, - sessionChangesApplied, - contextTokens: resolveContextTokens({ - cfg, - agentCfg, - provider: resolveContextConfigProviderForRuntime({ - provider, - runtimeId: resolveAgentHarnessPolicy({ - provider, - modelId: model, - config: cfg, - agentId: activeAgentId, - sessionKey, - }).runtime, - config: cfg, - }), - model, - modelContextWindow: selectedCatalogEntry?.contextWindow, - modelContextTokens: selectedCatalogEntry?.contextTokens, - }), - }; -} diff --git a/src/auto-reply/reply/directive-handling.shared.ts b/src/auto-reply/reply/directive-handling.shared.ts index c11daa812ea2..f9c11d270a49 100644 --- a/src/auto-reply/reply/directive-handling.shared.ts +++ b/src/auto-reply/reply/directive-handling.shared.ts @@ -11,10 +11,36 @@ import type { SessionEntry } from "../../config/sessions/types.js"; import { SYSTEM_MARK, prefixSystemMessage } from "../../infra/system-message.js"; import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js"; import { isInternalMessageChannel } from "../../utils/message-channel.js"; +import type { ReplyPayload } from "../types.js"; +import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import type { ElevatedLevel, ReasoningLevel } from "./directives.js"; import { persistReplySessionEntry } from "./session-entry-persistence.js"; +export const DIRECTIVE_ACK_MESSAGES = { + verbose: { + off: "Verbose logging disabled.", + on: "Verbose logging enabled.", + full: "Verbose logging set to full.", + }, + trace: { + off: "Trace disabled.", + on: "Trace enabled. Warning: trace output may contain sensitive information.", + raw: "Trace set to raw. Warning: trace output may contain sensitive information.", + }, + reasoning: { + off: "Reasoning visibility disabled.", + on: "Reasoning visibility enabled.", + stream: "Reasoning stream enabled.", + }, + elevated: { + off: "Elevated mode disabled.", + on: "Elevated mode set to ask (approvals may still apply).", + ask: "Elevated mode set to ask (approvals may still apply).", + full: "Elevated mode set to full (auto-approve).", + }, +} as const; + export const formatDirectiveAck = (text: string): string => { return prefixSystemMessage(text); }; @@ -118,6 +144,60 @@ export function resolveDirectiveTouchedSessionFields(params: { return [...fields]; } +export type IgnoredSessionDirectiveFlag = Extract; + +export function rejectSessionDirectiveTransaction( + persistenceState: HandleDirectiveOnlyParams["persistenceState"], + errorText: string, +): ReplyPayload { + if (persistenceState) { + persistenceState.outcome = { kind: "rejected", errorText }; + } + return { text: errorText }; +} + +/** Keeps the first informational/denied acknowledgement while committing valid siblings once. */ +export async function acknowledgeIgnoredSessionDirective(params: { + reply: ReplyPayload; + directives: InlineDirectives; + ignoredDirective: IgnoredSessionDirectiveFlag; + persistenceState: HandleDirectiveOnlyParams["persistenceState"]; + allowPrivilegedPersistence: boolean; + applyRemainingDirectives: (directives: InlineDirectives) => Promise; +}): Promise { + if (!params.persistenceState) { + return params.reply; + } + const { directives, ignoredDirective } = params; + const remainingDirectives = + ignoredDirective === "hasExecDirective" && directives.hasExecOptions + ? { + ...directives, + invalidExecHost: false, + invalidExecSecurity: false, + invalidExecAsk: false, + invalidExecNode: false, + } + : { + ...directives, + [ignoredDirective]: false, + ...(ignoredDirective === "hasThinkDirective" ? { clearThinkLevel: false } : {}), + ...(ignoredDirective === "hasFastDirective" ? { clearFastMode: false } : {}), + ...(ignoredDirective === "hasModelDirective" ? { rawModelProfile: undefined } : {}), + }; + const touchedFields = resolveDirectiveTouchedSessionFields({ + directives: remainingDirectives, + allowPrivilegedPersistence: params.allowPrivilegedPersistence, + }); + if (touchedFields.length > 0) { + const siblingReply = await params.applyRemainingDirectives(remainingDirectives); + if (params.persistenceState.outcome.kind === "rejected") { + return siblingReply ?? params.reply; + } + } + return params.reply; +} + /** Applies canonical session settings while each caller retains its authorization boundaries. */ export function applySessionDirectiveFields(params: { directives: InlineDirectives; @@ -219,7 +299,7 @@ export async function persistSessionDirectiveSnapshot(params: { touchedFields: Array; hasModelSelection: boolean; reassertLiveModelSwitchPending: boolean; -}): Promise<{ sessionChangesApplied: boolean; modelSelectionApplied: boolean }> { +}): Promise<{ status: "applied" | "conflict" | "model-selection-locked" }> { const { sessionEntry, sessionKey, sessionStore } = params; const persistence = await persistReplySessionEntry({ storePath: params.storePath, @@ -227,13 +307,17 @@ export async function persistSessionDirectiveSnapshot(params: { initialEntry: params.initialEntry, entry: sessionEntry, reassertLiveModelSwitchPending: params.reassertLiveModelSwitchPending, + requireModelSelectionUnlocked: params.hasModelSelection, touchedFields: params.touchedFields, }); if (persistence.status !== "current") { if (persistence.entry) { sessionStore[sessionKey] = persistence.entry; + adoptPersistedSessionSnapshot(sessionEntry, persistence.entry); } - return { sessionChangesApplied: false, modelSelectionApplied: false }; + return { + status: persistence.status === "model-selection-locked" ? persistence.status : "conflict", + }; } const persistedEntry = persistence.entry; @@ -254,7 +338,7 @@ export async function persistSessionDirectiveSnapshot(params: { reassertLiveModelSwitchPending: params.reassertLiveModelSwitchPending, })); adoptPersistedSessionSnapshot(sessionEntry, persistedEntry); - return { sessionChangesApplied, modelSelectionApplied }; + return { status: sessionChangesApplied && modelSelectionApplied ? "applied" : "conflict" }; } const formatElevatedEvent = (level: ElevatedLevel) => { diff --git a/src/auto-reply/reply/get-reply-directives-apply.test.ts b/src/auto-reply/reply/get-reply-directives-apply.test.ts index a1393ddbc3f8..c6be6ca37edc 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.test.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.test.ts @@ -7,8 +7,8 @@ import { createFastTestModelSelectionState } from "./model-selection.js"; import { buildTestCtx } from "./test-ctx.js"; const mocks = vi.hoisted(() => ({ - fastLane: vi.fn(), - persist: vi.fn(), + handleDirective: vi.fn(), + applyModelSelection: vi.fn(), systemEvent: vi.fn(), })); @@ -16,17 +16,17 @@ vi.mock("../../infra/system-events.js", () => ({ enqueueSystemEvent: (...args: unknown[]) => mocks.systemEvent(...args), })); -vi.mock("./directive-handling.fast-lane.js", () => ({ - applyInlineDirectivesFastLane: (...args: unknown[]) => mocks.fastLane(...args), +vi.mock("./directive-handling.impl.js", () => ({ + handleDirectiveOnly: (...args: unknown[]) => mocks.handleDirective(...args), })); vi.mock("./directive-handling.persist.runtime.js", () => ({ - persistInlineDirectives: (...args: unknown[]) => mocks.persist(...args), + applySessionModelSelection: (...args: unknown[]) => mocks.applyModelSelection(...args), })); beforeEach(() => { - mocks.fastLane.mockReset(); - mocks.persist.mockReset(); + mocks.handleDirective.mockReset(); + mocks.applyModelSelection.mockReset(); mocks.systemEvent.mockReset(); }); @@ -155,8 +155,8 @@ describe("applyInlineDirectiveOverrides", () => { reply: { text: MODEL_SELECTION_LOCKED_MESSAGE }, }); expect(typing.cleanup).toHaveBeenCalledOnce(); - expect(mocks.fastLane).not.toHaveBeenCalled(); - expect(mocks.persist).not.toHaveBeenCalled(); + expect(mocks.handleDirective).not.toHaveBeenCalled(); + expect(mocks.applyModelSelection).not.toHaveBeenCalled(); expect(mocks.systemEvent).toHaveBeenCalledWith(expected, { sessionKey: "agent:main:main", contextKey: "model:reset:openai/gpt-5.5", @@ -173,19 +173,12 @@ describe("applyInlineDirectiveOverrides", () => { }, ); - it("stops a mixed inline turn when final directive persistence loses", async () => { + it("stops a mixed inline turn when its single directive transaction loses", async () => { const directives = parseInlineDirectives("hello /elevated full"); - mocks.fastLane.mockResolvedValue({ - directiveAck: { text: "Elevated FULL enabled." }, - provider: "openai", - model: "gpt-5.5", - sessionChangesApplied: true, - }); - mocks.persist.mockResolvedValue({ - provider: "openai", - model: "gpt-5.5", - contextTokens: 8192, - sessionChangesApplied: false, + const errorText = "Session settings were not applied because the session changed. Retry."; + mocks.handleDirective.mockImplementation(async (params) => { + params.persistenceState.outcome = { kind: "rejected", errorText }; + return { text: errorText }; }); const typing = { onReplyStart: async () => {}, @@ -246,27 +239,19 @@ describe("applyInlineDirectiveOverrides", () => { expect(result).toEqual({ kind: "reply", - reply: { text: "Session settings were not applied because the session changed. Retry." }, + reply: { text: errorText }, }); expect(typing.cleanup).toHaveBeenCalledOnce(); + expect(mocks.handleDirective).toHaveBeenCalledOnce(); }); - it("stops a mixed inline turn when final thinking validation fails", async () => { + it("stops a mixed inline turn when its transaction rejects unsupported thinking", async () => { const errorText = 'Thinking level "ultra" is not supported for openai/gpt-5.6-luna. Use one of: off, low, medium, high, max.'; const directives = parseInlineDirectives("/think ultra please solve"); - mocks.fastLane.mockResolvedValue({ - directiveAck: { text: errorText }, - provider: "openai", - model: "gpt-5.6-luna", - sessionChangesApplied: true, - }); - mocks.persist.mockResolvedValue({ - provider: "openai", - model: "gpt-5.6-luna", - contextTokens: 372_000, - sessionChangesApplied: true, - errorText, + mocks.handleDirective.mockImplementation(async (params) => { + params.persistenceState.outcome = { kind: "rejected", errorText }; + return { text: errorText }; }); const typing = { onReplyStart: async () => {}, @@ -327,5 +312,6 @@ describe("applyInlineDirectiveOverrides", () => { expect(result).toEqual({ kind: "reply", reply: { text: errorText } }); expect(typing.cleanup).toHaveBeenCalledOnce(); + expect(mocks.handleDirective).toHaveBeenCalledOnce(); }); }); diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts index 610acb3aad26..f2ded06432df 100644 --- a/src/auto-reply/reply/get-reply-directives-apply.ts +++ b/src/auto-reply/reply/get-reply-directives-apply.ts @@ -1,4 +1,8 @@ // Applies parsed directives to session state, config overrides, and run options. +import { resolveSessionAgentId } from "../../agents/agent-scope.js"; +import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js"; +import { modelKey } from "../../agents/model-selection.js"; +import { resolveContextConfigProviderForRuntime } from "../../agents/openai-routing.js"; import type { SessionEntry, SessionScope } from "../../config/sessions/types.js"; import type { OpenClawConfig } from "../../config/types.openclaw.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; @@ -15,9 +19,10 @@ import { isDirectiveOnly } from "./directive-handling.directive-only.js"; import { resolveModelRuntimeDirective } from "./directive-handling.model-runtime.js"; import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js"; import { maybeHandleUnexpectedNativeDirectiveArguments } from "./directive-handling.native.js"; -import type { ApplyInlineDirectivesFastLaneParams } from "./directive-handling.params.js"; +import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js"; import type { InlineDirectives } from "./directive-handling.parse.js"; import { clearInlineDirectives } from "./get-reply-directives-utils.js"; +import { resolveContextTokens } from "./model-selection-context.js"; import type { createModelSelectionState } from "./model-selection.js"; import type { TypingController } from "./typing.js"; @@ -29,9 +34,6 @@ const directiveLevelsLoader = createLazyImportLoader( () => import("./directive-handling.levels.js"), ); const directiveImplLoader = createLazyImportLoader(() => import("./directive-handling.impl.js")); -const directiveFastLaneLoader = createLazyImportLoader( - () => import("./directive-handling.fast-lane.js"), -); const directivePersistLoader = createLazyImportLoader( () => import("./directive-handling.persist.runtime.js"), ); @@ -48,10 +50,6 @@ function loadDirectiveImpl() { return directiveImplLoader.load(); } -function loadDirectiveFastLane() { - return directiveFastLaneLoader.load(); -} - function loadDirectivePersist() { return directivePersistLoader.load(); } @@ -139,7 +137,7 @@ export async function applyInlineDirectiveOverrides(params: { elevatedFailures: Array<{ gate: string; key: string }>; defaultProvider: string; defaultModel: string; - aliasIndex: ApplyInlineDirectivesFastLaneParams["aliasIndex"]; + aliasIndex: HandleDirectiveOnlyParams["aliasIndex"]; provider: string; model: string; modelState: Awaited>; @@ -288,34 +286,6 @@ export async function applyInlineDirectiveOverrides(params: { }; } - const directivePersistenceContext = { - directives, - effectiveModelDirective, - cfg, - agentDir, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - defaultProvider, - defaultModel, - aliasIndex, - allowedModelKeys: modelState.allowedModelKeys, - modelCatalog: modelState.allowedModelCatalog, - thinkingCatalog: modelState.allowedModelCatalog, - initialModelLabel, - formatModelSwitchEvent, - canPersistStickyModelSelection, - agentCfg, - messageProvider: ctx.Provider, - surface: ctx.Surface, - gatewayClientScopes: ctx.GatewayClientScopes, - commandAuthorized: command.isAuthorizedSender, - senderIsOwner: command.senderIsOwner, - }; - // Model-only directives have a focused persistence service; reject leftovers before that mutation. if (directives.nativeCommand?.name === "model") { const unexpectedNativeArguments = maybeHandleUnexpectedNativeDirectiveArguments(directives); @@ -325,16 +295,49 @@ export async function applyInlineDirectiveOverrides(params: { } } - if ( - isDirectiveOnly({ - directives, - cleanedBody: directives.cleaned, + const directiveOnly = isDirectiveOnly({ + directives, + cleanedBody: directives.cleaned, + ctx, + cfg, + agentId, + isGroup, + }); + + const handleDirectives = async ( + persistenceState?: NonNullable, + ) => { + const currentLevels = await ( + await loadDirectiveLevels() + ).resolveCurrentDirectiveLevels({ + sessionEntry, + agentEntry: persistenceState ? undefined : agentEntry, + agentCfg, + resolveDefaultThinkingLevel: + !persistenceState || directives.hasThinkDirective + ? () => modelState.resolveDefaultThinkingLevel() + : async () => undefined, + }); + const thinkingCatalog = await modelState.resolveThinkingCatalog(); + const reply = await ( + await loadDirectiveImpl() + ).handleDirectiveOnly({ + ...createDirectiveHandlingBase(), + ...currentLevels, + thinkingCatalog, ctx, - cfg, - agentId, - isGroup, - }) - ) { + messageProvider: ctx.Provider, + surface: ctx.Surface, + gatewayClientScopes: ctx.GatewayClientScopes, + commandAuthorized: command.isAuthorizedSender, + senderIsOwner: command.senderIsOwner, + workspaceDir, + ...(persistenceState ? { persistenceState } : {}), + }); + return { reply, currentLevels, thinkingCatalog }; + }; + + if (directiveOnly) { if (!command.isAuthorizedSender) { typing.cleanup(); return { kind: "reply", reply: undefined }; @@ -430,40 +433,12 @@ export async function applyInlineDirectiveOverrides(params: { return { kind: "reply", reply: { text: parts.join(" ") } }; } } + const { reply: directiveReply, currentLevels, thinkingCatalog } = await handleDirectives(); const { currentThinkLevel: resolvedDefaultThinkLevel, - currentFastMode, currentVerboseLevel, currentReasoningLevel, - currentElevatedLevel, - } = await ( - await loadDirectiveLevels() - ).resolveCurrentDirectiveLevels({ - sessionEntry, - agentEntry, - agentCfg, - resolveDefaultThinkingLevel: () => modelState.resolveDefaultThinkingLevel(), - }); - const currentThinkLevel = resolvedDefaultThinkLevel; - const thinkingCatalog = await modelState.resolveThinkingCatalog(); - const directiveReply = await ( - await loadDirectiveImpl() - ).handleDirectiveOnly({ - ...createDirectiveHandlingBase(), - thinkingCatalog, - currentThinkLevel, - currentFastMode, - currentVerboseLevel, - currentReasoningLevel, - currentElevatedLevel, - ctx, - messageProvider: ctx.Provider, - surface: ctx.Surface, - gatewayClientScopes: ctx.GatewayClientScopes, - commandAuthorized: command.isAuthorizedSender, - senderIsOwner: command.senderIsOwner, - workspaceDir, - }); + } = currentLevels; let statusReply: ReplyPayload | undefined; if (directives.hasStatusDirective && allowTextCommands && command.isAuthorizedSender) { const { buildStatusReply } = await loadCommandsStatus(); @@ -502,80 +477,41 @@ export async function applyInlineDirectiveOverrides(params: { } if (hasAnyDirective && command.isAuthorizedSender) { - const fastLane = await ( - await loadDirectiveFastLane() - ).applyInlineDirectivesFastLane({ - directives, - commandAuthorized: command.isAuthorizedSender, - senderIsOwner: command.senderIsOwner, - ctx, - workspaceDir, - cfg, - agentId, - isGroup, - sessionEntry, - sessionStore, - sessionKey, - storePath, - elevatedEnabled, - elevatedAllowed, - elevatedFailures, - messageProviderKey, - defaultProvider, - defaultModel, - aliasIndex, - ...directiveModelState, - provider, - model, - initialModelLabel, - formatModelSwitchEvent, - canPersistStickyModelSelection, - agentCfg, - modelState: { - resolveDefaultThinkingLevel: modelState.resolveDefaultThinkingLevel, - resolveThinkingCatalog: modelState.resolveThinkingCatalog, - ...directiveModelState, - }, - }); - directiveAck = fastLane.directiveAck; - provider = fastLane.provider; - model = fastLane.model; - if (!fastLane.sessionChangesApplied) { + const persistenceState: NonNullable = { + outcome: { kind: "pending", provider, model }, + }; + directiveAck = (await handleDirectives(persistenceState)).reply; + if (persistenceState.outcome.kind === "rejected") { typing.cleanup(); return { kind: "reply", - reply: - directiveAck ?? - ({ - text: "Session settings were not applied because the session changed. Retry.", - } satisfies ReplyPayload), + reply: { text: persistenceState.outcome.errorText }, }; } + ({ provider, model } = persistenceState.outcome); } - const persisted = await ( - await loadDirectivePersist() - ).persistInlineDirectives({ - ...directivePersistenceContext, - provider, + const selectedCatalogEntry = modelState.allowedModelCatalog.find( + (entry) => modelKey(entry.provider, entry.id) === modelKey(provider, model), + ); + contextTokens = resolveContextTokens({ + cfg, + agentCfg, + provider: resolveContextConfigProviderForRuntime({ + provider, + runtimeId: resolveAgentHarnessPolicy({ + provider, + modelId: model, + config: cfg, + agentId: resolveSessionAgentId({ sessionKey, config: cfg }), + sessionKey, + }).runtime, + config: cfg, + }), model, + modelContextWindow: selectedCatalogEntry?.contextWindow, + modelContextTokens: selectedCatalogEntry?.contextTokens, }); - provider = persisted.provider; - model = persisted.model; - contextTokens = persisted.contextTokens; - if (persisted.errorText) { - typing.cleanup(); - return { kind: "reply", reply: { text: persisted.errorText } }; - } - if (!persisted.sessionChangesApplied) { - typing.cleanup(); - return { - kind: "reply", - reply: { - text: "Session settings were not applied because the session changed. Retry.", - }, - }; - } const perMessageQueueMode = directives.hasQueueDirective && !directives.queueReset ? directives.queueMode : undefined; diff --git a/src/channels/plugins/bundled.ts b/src/channels/plugins/bundled.ts index 5e2ccac8ba71..322207d8eafe 100644 --- a/src/channels/plugins/bundled.ts +++ b/src/channels/plugins/bundled.ts @@ -77,30 +77,35 @@ type BundledChannelPackageSetupFeature = | "legacyStateMigrations" | "legacySessionSurfaces"; -type GeneratedBundledChannelEntry = { - id: string; +type BundledChannelArtifactValues = { entry: BundledChannelEntryRuntimeContract; + setupEntry: BundledChannelSetupEntryRuntimeContract; + plugin: ChannelPlugin; + setupPlugin: ChannelPlugin; + secrets: NonNullable; + setupSecrets: NonNullable; + accountInspector: NonNullable; }; +type BundledChannelArtifactKind = keyof BundledChannelArtifactValues; +type BundledChannelEntryKind = "entry" | "setupEntry"; +type BundledChannelArtifacts = Partial<{ + [Kind in BundledChannelArtifactKind]: BundledChannelArtifactValues[Kind] | null; +}>; + type BundledChannelLoadContext = { - pluginLoadInProgressIds: Set; - setupPluginLoadInProgressIds: Set; - entryLoadInProgressIds: Set; - setupEntryLoadInProgressIds: Set; - lazyEntriesById: Map; - lazySetupEntriesById: Map; - lazyPluginsById: Map; - lazySetupPluginsById: Map; - lazySecretsById: Map; - lazySetupSecretsById: Map; - lazyAccountInspectorsById: Map< - ChannelId, - NonNullable | null - >; + artifactLoadsInProgress: Set; + artifactsById: Map; metadataById: Map; metadataLoaded: boolean; }; +type BundledChannelArtifactLoadParams = { + id: ChannelId; + rootScope: BundledChannelRootScope; + loadContext: BundledChannelLoadContext; +}; + const log = createSubsystemLogger("channels"); const MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS = 32; const MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS = 256; @@ -108,6 +113,23 @@ const bundledChannelLoadContextsByRoot = new Map(); const sourceBundledEntryLoaderCache: PluginModuleLoaderCache = new Map(); +function rememberBoundedBundledChannelValue( + cache: Map, + key: TKey, + value: TValue, + maxSize: number, +): TValue { + cache.delete(key); + cache.set(key, value); + if (cache.size > maxSize) { + const oldestKey = cache.keys().next().value; + if (oldestKey !== undefined) { + cache.delete(oldestKey); + } + } + return value; +} + function isSourceModulePath(modulePath: string): boolean { return /\.(?:c|m)?tsx?$/iu.test(modulePath); } @@ -141,51 +163,28 @@ function isPackageLocalBundledDistModulePath(params: { return distRoots.some((root) => isPathInsideCanonicalRoot(root, params.modulePath)); } -function resolveChannelPluginModuleEntry( +function resolveBundledChannelModuleEntry( moduleExport: unknown, -): BundledChannelEntryRuntimeContract | null { + kind: TKind, +): BundledChannelArtifactValues[TKind] | null { const resolved = unwrapDefaultModuleExport(moduleExport); if (!resolved || typeof resolved !== "object") { return null; } - const record = resolved as Partial; - if (record.kind !== "bundled-channel-entry") { + const record = resolved as Record; + const setup = kind === "setupEntry"; + if (record.kind !== (setup ? "bundled-channel-setup-entry" : "bundled-channel-entry")) { return null; } + const stringFields = setup ? [] : ["id", "name", "description"]; + const functionFields = setup ? ["loadSetupPlugin"] : ["register", "loadChannelPlugin"]; if ( - typeof record.id !== "string" || - typeof record.name !== "string" || - typeof record.description !== "string" || - typeof record.register !== "function" || - typeof record.loadChannelPlugin !== "function" + stringFields.some((field) => typeof record[field] !== "string") || + functionFields.some((field) => typeof record[field] !== "function") ) { return null; } - return record as BundledChannelEntryRuntimeContract; -} - -function resolveChannelSetupModuleEntry( - moduleExport: unknown, -): BundledChannelSetupEntryRuntimeContract | null { - const resolved = unwrapDefaultModuleExport(moduleExport); - if (!resolved || typeof resolved !== "object") { - return null; - } - const record = resolved as Partial; - if (record.kind !== "bundled-channel-setup-entry") { - return null; - } - if (typeof record.loadSetupPlugin !== "function") { - return null; - } - return record as BundledChannelSetupEntryRuntimeContract; -} - -function hasSetupEntryFeature( - entry: BundledChannelSetupEntryRuntimeContract | null | undefined, - feature: keyof NonNullable, -): boolean { - return entry?.features?.[feature] === true; + return record as BundledChannelArtifactValues[TKind]; } function resolveBundledChannelBoundaryRoot(params: { @@ -202,59 +201,33 @@ function resolveBundledChannelBoundaryRoot(params: { ].join("\0"); const cached = bundledChannelBoundaryRoots.get(cacheKey); if (cached) { - bundledChannelBoundaryRoots.delete(cacheKey); - bundledChannelBoundaryRoots.set(cacheKey, cached); - return cached; + return rememberBoundedBundledChannelValue( + bundledChannelBoundaryRoots, + cacheKey, + cached, + MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS, + ); } const canonicalModulePath = resolveCanonicalPathOrAbsolute(params.modulePath); - const resolveMatchingRoot = (root: string): string | null => { - const canonicalRoot = resolveCanonicalPathOrAbsolute(root); - return isPathInside(canonicalRoot, canonicalModulePath) ? canonicalRoot : null; - }; - const overrideRoot = params.pluginsDir - ? path.resolve(params.pluginsDir, params.metadata.dirName) - : null; - let boundaryRoot: string; - const overrideBoundaryRoot = overrideRoot ? resolveMatchingRoot(overrideRoot) : null; - if (overrideBoundaryRoot) { - boundaryRoot = overrideBoundaryRoot; - } else { - const distRoot = path.resolve( - params.packageRoot, - "dist", - "extensions", - params.metadata.dirName, - ); - const distBoundaryRoot = resolveMatchingRoot(distRoot); - if (distBoundaryRoot) { - boundaryRoot = distBoundaryRoot; - } else { - const distRuntimeRoot = path.resolve( - params.packageRoot, - "dist-runtime", - "extensions", - params.metadata.dirName, - ); - boundaryRoot = - resolveMatchingRoot(distRuntimeRoot) ?? - resolveCanonicalPathOrAbsolute( - path.resolve(params.packageRoot, "extensions", params.metadata.dirName), - ); - } - } - bundledChannelBoundaryRoots.set(cacheKey, boundaryRoot); - while (bundledChannelBoundaryRoots.size > MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS) { - const oldestKey = bundledChannelBoundaryRoots.keys().next().value; - if (oldestKey === undefined) { - break; - } - bundledChannelBoundaryRoots.delete(oldestKey); - } - return boundaryRoot; -} - -function resolveBundledChannelScanDir(rootScope: BundledChannelRootScope): string | undefined { - return rootScope.pluginsDir; + const sourceRoot = path.resolve(params.packageRoot, "extensions", params.metadata.dirName); + const candidates = [ + ...(params.pluginsDir ? [path.resolve(params.pluginsDir, params.metadata.dirName)] : []), + ...["dist", "dist-runtime"].map((layout) => + path.resolve(params.packageRoot, layout, "extensions", params.metadata.dirName), + ), + sourceRoot, + ]; + const boundaryRoot = + candidates + .map(resolveCanonicalPathOrAbsolute) + .find((root) => isPathInside(root, canonicalModulePath)) ?? + resolveCanonicalPathOrAbsolute(sourceRoot); + return rememberBoundedBundledChannelValue( + bundledChannelBoundaryRoots, + cacheKey, + boundaryRoot, + MAX_BUNDLED_CHANNEL_BOUNDARY_ROOTS, + ); } function resolveGeneratedBundledChannelModulePath(params: { @@ -269,7 +242,7 @@ function resolveGeneratedBundledChannelModulePath(params: { params.rootScope.packageRoot, params.entry, params.metadata.dirName, - resolveBundledChannelScanDir(params.rootScope), + params.rootScope.pluginsDir, ); if (generatedPath) { return generatedPath; @@ -318,7 +291,7 @@ function loadGeneratedBundledChannelModule(params: { if (!modulePath) { throw new Error(`missing generated module for bundled channel ${params.metadata.manifest.id}`); } - const scanDir = resolveBundledChannelScanDir(params.rootScope); + const scanDir = params.rootScope.pluginsDir; const boundaryRoot = resolveBundledChannelBoundaryRoot({ packageRoot: params.rootScope.packageRoot, ...(scanDir ? { pluginsDir: scanDir } : {}), @@ -382,79 +355,47 @@ function describeBundledChannelLoadError(error: unknown, channelId: string): str return detail; } -function loadGeneratedBundledChannelEntry(params: { - rootScope: BundledChannelRootScope; - metadata: BundledChannelPluginMetadata; -}): GeneratedBundledChannelEntry | null { +function loadGeneratedBundledChannelEntry( + kind: TKind, + rootScope: BundledChannelRootScope, + metadata: BundledChannelPluginMetadata, +): BundledChannelArtifactValues[TKind] | undefined { + const setup = kind === "setupEntry"; + const source = setup ? metadata.setupSource : metadata.source; + if (setup && !source) { + return undefined; + } try { - const entry = resolveChannelPluginModuleEntry( + const entry = resolveBundledChannelModuleEntry( loadGeneratedBundledChannelModule({ - rootScope: params.rootScope, - metadata: params.metadata, - entry: params.metadata.source, + rootScope, + metadata, + entry: source, }), + kind, ); if (!entry) { + const description = setup ? "setup entry" : "entry"; + const contract = setup ? "bundled-channel-setup-entry" : "bundled-channel-entry"; log.warn( - `[channels] bundled channel entry ${params.metadata.manifest.id} missing bundled-channel-entry contract; skipping`, + `[channels] bundled channel ${description} ${metadata.manifest.id} missing ${contract} contract; skipping`, ); - return null; } - return { - id: params.metadata.manifest.id, - entry, - }; + return entry ?? undefined; } catch (error) { - const detail = describeBundledChannelLoadError(error, params.metadata.manifest.id); - log.warn(`[channels] failed to load bundled channel ${params.metadata.manifest.id}: ${detail}`); - return null; - } -} - -function loadGeneratedBundledChannelSetupEntry(params: { - rootScope: BundledChannelRootScope; - metadata: BundledChannelPluginMetadata; -}): BundledChannelSetupEntryRuntimeContract | null { - if (!params.metadata.setupSource) { - return null; - } - try { - const setupEntry = resolveChannelSetupModuleEntry( - loadGeneratedBundledChannelModule({ - rootScope: params.rootScope, - metadata: params.metadata, - entry: params.metadata.setupSource, - }), - ); - if (!setupEntry) { - log.warn( - `[channels] bundled channel setup entry ${params.metadata.manifest.id} missing bundled-channel-setup-entry contract; skipping`, - ); - return null; - } - return setupEntry; - } catch (error) { - const detail = describeBundledChannelLoadError(error, params.metadata.manifest.id); + const detail = describeBundledChannelLoadError(error, metadata.manifest.id); + const description = setup ? " setup entry" : ""; log.warn( - `[channels] failed to load bundled channel setup entry ${params.metadata.manifest.id}: ${detail}`, + `[channels] failed to load bundled channel${description} ${metadata.manifest.id}: ${detail}`, ); - return null; + return undefined; } } function createBundledChannelLoadContext(): BundledChannelLoadContext { return { - pluginLoadInProgressIds: new Set(), - setupPluginLoadInProgressIds: new Set(), - entryLoadInProgressIds: new Set(), - setupEntryLoadInProgressIds: new Set(), - lazyEntriesById: new Map(), - lazySetupEntriesById: new Map(), - lazyPluginsById: new Map(), - lazySetupPluginsById: new Map(), - lazySecretsById: new Map(), - lazySetupSecretsById: new Map(), - lazyAccountInspectorsById: new Map(), + artifactLoadsInProgress: new Set(), + artifactsById: new Map(), metadataById: new Map(), metadataLoaded: false, }; @@ -465,24 +406,12 @@ function resolveActiveBundledChannelLoadScope(env: NodeJS.ProcessEnv = process.e loadContext: BundledChannelLoadContext; } { const rootScope = resolveBundledChannelRootScope(env); - const cachedContext = bundledChannelLoadContextsByRoot.get(rootScope.cacheKey); - if (cachedContext) { - bundledChannelLoadContextsByRoot.delete(rootScope.cacheKey); - bundledChannelLoadContextsByRoot.set(rootScope.cacheKey, cachedContext); - return { - rootScope, - loadContext: cachedContext, - }; - } - const loadContext = createBundledChannelLoadContext(); - bundledChannelLoadContextsByRoot.set(rootScope.cacheKey, loadContext); - while (bundledChannelLoadContextsByRoot.size > MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS) { - const oldestKey = bundledChannelLoadContextsByRoot.keys().next().value; - if (oldestKey === undefined) { - break; - } - bundledChannelLoadContextsByRoot.delete(oldestKey); - } + const loadContext = rememberBoundedBundledChannelValue( + bundledChannelLoadContextsByRoot, + rootScope.cacheKey, + bundledChannelLoadContextsByRoot.get(rootScope.cacheKey) ?? createBundledChannelLoadContext(), + MAX_BUNDLED_CHANNEL_LOAD_CONTEXTS, + ); return { rootScope, loadContext, @@ -492,7 +421,7 @@ function resolveActiveBundledChannelLoadScope(env: NodeJS.ProcessEnv = process.e function listBundledChannelMetadata( rootScope = resolveBundledChannelRootScope(), ): readonly BundledChannelPluginMetadata[] { - const scanDir = resolveBundledChannelScanDir(rootScope); + const scanDir = rootScope.pluginsDir; return listBundledChannelPluginMetadata({ rootDir: rootScope.packageRoot, ...(scanDir ? { scanDir } : {}), @@ -554,28 +483,15 @@ function listBundledChannelPluginIdsForSetupFeature( feature: keyof NonNullable, options: { config?: OpenClawConfig } = {}, ): readonly ChannelId[] { - const hinted = listBundledChannelMetadata(rootScope) - .filter( - (metadata) => - metadata.packageManifest?.setupFeatures?.[feature] === true && - shouldIncludeBundledChannelSetupFeatureForConfig({ - metadata, - config: options.config, - }), - ) + const eligible = listBundledChannelMetadata(rootScope).filter((metadata) => + shouldIncludeBundledChannelSetupFeatureForConfig({ metadata, config: options.config }), + ); + const hinted = eligible.filter( + (metadata) => metadata.packageManifest?.setupFeatures?.[feature] === true, + ); + return (hinted.length > 0 ? hinted : eligible) .map((metadata) => metadata.manifest.id) .toSorted((left, right) => left.localeCompare(right)); - return hinted.length > 0 - ? hinted - : listBundledChannelMetadata(rootScope) - .filter((metadata) => - shouldIncludeBundledChannelSetupFeatureForConfig({ - metadata, - config: options.config, - }), - ) - .map((metadata) => metadata.manifest.id) - .toSorted((left, right) => left.localeCompare(right)); } export function listBundledChannelPluginIds(): readonly ChannelId[] { @@ -621,241 +537,146 @@ function resolveBundledChannelMetadata( return undefined; } -function getLazyGeneratedBundledChannelEntryForRoot( +function rememberBundledChannelArtifact( + loadContext: BundledChannelLoadContext, + kind: TKind, + id: ChannelId, + artifact: BundledChannelArtifactValues[TKind] | undefined, +): void { + const artifacts = loadContext.artifactsById.get(id) ?? {}; + artifacts[kind] = artifact ?? null; + loadContext.artifactsById.set(id, artifacts); +} + +function getBundledChannelArtifactForRoot( + kind: TKind, id: ChannelId, rootScope: BundledChannelRootScope, loadContext: BundledChannelLoadContext, -): GeneratedBundledChannelEntry | null { - const previous = loadContext.lazyEntriesById.get(id); - if (previous) { - return previous; +): BundledChannelArtifactValues[TKind] | undefined { + const artifacts = loadContext.artifactsById.get(id); + if (artifacts && Object.hasOwn(artifacts, kind)) { + return artifacts[kind] ?? undefined; } - if (previous === null) { - return null; + // Keep failure and recursion state separate by artifact kind: broken secrets + // must never poison the runtime plugin, setup plugin, or entry contracts. + const loadKey = `${kind}\0${id}`; + if (loadContext.artifactLoadsInProgress.has(loadKey)) { + return undefined; } - const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); - if (!metadata) { - loadContext.lazyEntriesById.set(id, null); - return null; - } - if (loadContext.entryLoadInProgressIds.has(id)) { - return null; - } - loadContext.entryLoadInProgressIds.add(id); + loadContext.artifactLoadsInProgress.add(loadKey); try { - const entry = loadGeneratedBundledChannelEntry({ - rootScope, - metadata, - }); - loadContext.lazyEntriesById.set(id, entry); - if (entry?.entry.id && entry.entry.id !== id) { - loadContext.lazyEntriesById.set(entry.entry.id, entry); + const artifact = bundledChannelArtifactLoaders[kind]({ id, rootScope, loadContext }); + rememberBundledChannelArtifact(loadContext, kind, id, artifact); + return artifact; + } catch (error) { + if (kind === "entry" || kind === "setupEntry") { + throw error; } - return entry; - } finally { - loadContext.entryLoadInProgressIds.delete(id); - } -} - -function rememberBundledChannelSetupEntry( - metadata: BundledChannelPluginMetadata, - loadContext: BundledChannelLoadContext, - entry: BundledChannelSetupEntryRuntimeContract | null, - requestedId?: ChannelId, -) { - const ids = new Set([ - metadata.manifest.id, - ...(metadata.manifest.channels ?? []), - ...(requestedId ? [requestedId] : []), - ]); - for (const id of ids) { - loadContext.lazySetupEntriesById.set(id, entry); - } -} - -function getLazyGeneratedBundledChannelSetupEntryForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): BundledChannelSetupEntryRuntimeContract | null { - if (loadContext.lazySetupEntriesById.has(id)) { - return loadContext.lazySetupEntriesById.get(id) ?? null; - } - const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); - if (!metadata) { - loadContext.lazySetupEntriesById.set(id, null); - return null; - } - if (loadContext.setupEntryLoadInProgressIds.has(id)) { - return null; - } - loadContext.setupEntryLoadInProgressIds.add(id); - try { - const setupEntry = loadGeneratedBundledChannelSetupEntry({ - rootScope, - metadata, - }); - rememberBundledChannelSetupEntry(metadata, loadContext, setupEntry, id); - return setupEntry; - } finally { - loadContext.setupEntryLoadInProgressIds.delete(id); - } -} - -function getBundledChannelPluginForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin | undefined { - if (loadContext.lazyPluginsById.has(id)) { - return loadContext.lazyPluginsById.get(id) ?? undefined; - } - if (loadContext.pluginLoadInProgressIds.has(id)) { + const descriptions: Record = { + entry: "", + setupEntry: " setup entry", + plugin: "", + setupPlugin: " setup", + secrets: " secrets", + setupSecrets: " setup secrets", + accountInspector: " account inspector", + }; + const detail = describeBundledChannelLoadError(error, id); + log.warn(`[channels] failed to load bundled channel${descriptions[kind]} ${id}: ${detail}`); + rememberBundledChannelArtifact(loadContext, kind, id, undefined); return undefined; + } finally { + loadContext.artifactLoadsInProgress.delete(loadKey); } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; - if (!entry) { - return undefined; - } - loadContext.pluginLoadInProgressIds.add(id); - try { +} + +const bundledChannelArtifactLoaders: { + [Kind in BundledChannelArtifactKind]: ( + params: BundledChannelArtifactLoadParams, + ) => BundledChannelArtifactValues[Kind] | undefined; +} = { + entry({ id, rootScope, loadContext }) { const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); - const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; - if (!plugin) { - loadContext.lazyPluginsById.set(id, null); + if (!metadata) { return undefined; } - const normalizedPlugin = { - ...plugin, - meta: normalizeChannelMeta({ - id: plugin.id, - meta: plugin.meta, - existing: metadata?.packageManifest?.channel, - }), - }; - loadContext.lazyPluginsById.set(id, normalizedPlugin); - return normalizedPlugin; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel ${id}: ${detail}`); - loadContext.lazyPluginsById.set(id, null); - return undefined; - } finally { - loadContext.pluginLoadInProgressIds.delete(id); - } -} - -function getBundledChannelSecretsForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin["secrets"] | undefined { - if (loadContext.lazySecretsById.has(id)) { - return loadContext.lazySecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; - if (!entry) { - return undefined; - } - try { - const secrets = - entry.loadChannelSecrets?.() ?? - getBundledChannelPluginForRoot(id, rootScope, loadContext)?.secrets; - loadContext.lazySecretsById.set(id, secrets ?? null); - return secrets; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel secrets ${id}: ${detail}`); - loadContext.lazySecretsById.set(id, null); - return undefined; - } -} - -function getBundledChannelAccountInspectorForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): NonNullable | undefined { - if (loadContext.lazyAccountInspectorsById.has(id)) { - return loadContext.lazyAccountInspectorsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry; - if (!entry?.loadChannelAccountInspector) { - loadContext.lazyAccountInspectorsById.set(id, null); - return undefined; - } - try { - const inspector = entry.loadChannelAccountInspector(); - loadContext.lazyAccountInspectorsById.set(id, inspector); - return inspector; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel account inspector ${id}: ${detail}`); - loadContext.lazyAccountInspectorsById.set(id, null); - return undefined; - } -} - -function getBundledChannelSetupPluginForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin | undefined { - if (loadContext.lazySetupPluginsById.has(id)) { - return loadContext.lazySetupPluginsById.get(id) ?? undefined; - } - if (loadContext.setupPluginLoadInProgressIds.has(id)) { - return undefined; - } - const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - if (!entry) { - return undefined; - } - loadContext.setupPluginLoadInProgressIds.add(id); - try { - const plugin = entry.loadSetupPlugin(); - loadContext.lazySetupPluginsById.set(id, plugin); - return plugin; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel setup ${id}: ${detail}`); - loadContext.lazySetupPluginsById.set(id, null); - return undefined; - } finally { - loadContext.setupPluginLoadInProgressIds.delete(id); - } -} - -function getBundledChannelSetupSecretsForRoot( - id: ChannelId, - rootScope: BundledChannelRootScope, - loadContext: BundledChannelLoadContext, -): ChannelPlugin["secrets"] | undefined { - if (loadContext.lazySetupSecretsById.has(id)) { - return loadContext.lazySetupSecretsById.get(id) ?? undefined; - } - const entry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - if (!entry) { - return undefined; - } - try { - const secrets = - entry.loadSetupSecrets?.() ?? - getBundledChannelSetupPluginForRoot(id, rootScope, loadContext)?.secrets; - loadContext.lazySetupSecretsById.set(id, secrets ?? null); - return secrets; - } catch (error) { - const detail = describeBundledChannelLoadError(error, id); - log.warn(`[channels] failed to load bundled channel setup secrets ${id}: ${detail}`); - loadContext.lazySetupSecretsById.set(id, null); - return undefined; - } -} + const entry = loadGeneratedBundledChannelEntry("entry", rootScope, metadata); + if (entry && entry.id !== id) { + rememberBundledChannelArtifact(loadContext, "entry", entry.id, entry); + } + return entry; + }, + setupEntry({ id, rootScope, loadContext }) { + const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); + if (!metadata) { + return undefined; + } + const entry = loadGeneratedBundledChannelEntry("setupEntry", rootScope, metadata); + const aliases = new Set([ + metadata.manifest.id, + ...(metadata.manifest.channels ?? []), + id, + ]); + for (const alias of aliases) { + rememberBundledChannelArtifact(loadContext, "setupEntry", alias, entry); + } + return entry; + }, + plugin({ id, rootScope, loadContext }) { + const entry = getBundledChannelArtifactForRoot("entry", id, rootScope, loadContext); + if (!entry) { + return undefined; + } + const metadata = resolveBundledChannelMetadata(id, rootScope, loadContext); + const plugin = entry.loadChannelPlugin() as ChannelPlugin | undefined; + return plugin + ? { + ...plugin, + meta: normalizeChannelMeta({ + id: plugin.id, + meta: plugin.meta, + existing: metadata?.packageManifest?.channel, + }), + } + : undefined; + }, + setupPlugin({ id, rootScope, loadContext }) { + return getBundledChannelArtifactForRoot( + "setupEntry", + id, + rootScope, + loadContext, + )?.loadSetupPlugin(); + }, + secrets({ id, rootScope, loadContext }) { + const entry = getBundledChannelArtifactForRoot("entry", id, rootScope, loadContext); + return entry + ? (entry.loadChannelSecrets?.() ?? + getBundledChannelArtifactForRoot("plugin", id, rootScope, loadContext)?.secrets) + : undefined; + }, + setupSecrets({ id, rootScope, loadContext }) { + const entry = getBundledChannelArtifactForRoot("setupEntry", id, rootScope, loadContext); + return entry + ? (entry.loadSetupSecrets?.() ?? + getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext)?.secrets) + : undefined; + }, + accountInspector({ id, rootScope, loadContext }) { + return getBundledChannelArtifactForRoot( + "entry", + id, + rootScope, + loadContext, + )?.loadChannelAccountInspector?.(); + }, +}; export function listBundledChannelPlugins(): readonly ChannelPlugin[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const plugin = getBundledChannelPluginForRoot(id, rootScope, loadContext); + const plugin = getBundledChannelArtifactForRoot("plugin", id, rootScope, loadContext); return plugin ? [plugin] : []; }); } @@ -863,72 +684,70 @@ export function listBundledChannelPlugins(): readonly ChannelPlugin[] { export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => { - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); + const plugin = getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); return plugin ? [plugin] : []; }); } -export function listBundledChannelLegacySessionSurfaces( - options: { - config?: OpenClawConfig; - } = {}, -): readonly BundledChannelLegacySessionSurface[] { +function listBundledChannelLegacyArtifacts( + feature: keyof NonNullable, + options: { config?: OpenClawConfig }, + loadFromEntry: (entry: BundledChannelSetupEntryRuntimeContract) => TArtifact | undefined, + loadFromPlugin: (plugin: ChannelPlugin) => TArtifact | undefined, +): readonly TArtifact[] { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacySessionSurfaces", { - config: options.config, - }).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - const surface = setupEntry?.loadLegacySessionSurface?.(); - if (surface) { - return [surface]; + return listBundledChannelPluginIdsForSetupFeature(rootScope, feature, options).flatMap((id) => { + const entry = getBundledChannelArtifactForRoot("setupEntry", id, rootScope, loadContext); + const artifact = entry ? loadFromEntry(entry) : undefined; + if (artifact) { + return [artifact]; } - if (!hasSetupEntryFeature(setupEntry, "legacySessionSurfaces")) { + if (entry?.features?.[feature] !== true) { return []; } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); - return plugin?.messaging ? [plugin.messaging] : []; + const plugin = getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); + const fallback = plugin ? loadFromPlugin(plugin) : undefined; + return fallback ? [fallback] : []; }); } +export function listBundledChannelLegacySessionSurfaces( + options: { config?: OpenClawConfig } = {}, +): readonly BundledChannelLegacySessionSurface[] { + return listBundledChannelLegacyArtifacts( + "legacySessionSurfaces", + options, + (entry) => entry.loadLegacySessionSurface?.(), + (plugin) => plugin.messaging, + ); +} + export function listBundledChannelLegacyStateMigrationDetectors( - options: { - config?: OpenClawConfig; - } = {}, + options: { config?: OpenClawConfig } = {}, ): readonly BundledChannelLegacyStateMigrationDetector[] { - const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return listBundledChannelPluginIdsForSetupFeature(rootScope, "legacyStateMigrations", { - config: options.config, - }).flatMap((id) => { - const setupEntry = getLazyGeneratedBundledChannelSetupEntryForRoot(id, rootScope, loadContext); - const detector = setupEntry?.loadLegacyStateMigrationDetector?.(); - if (detector) { - return [detector]; - } - if (!hasSetupEntryFeature(setupEntry, "legacyStateMigrations")) { - return []; - } - const plugin = getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); - return plugin?.lifecycle?.detectLegacyStateMigrations - ? [plugin.lifecycle.detectLegacyStateMigrations] - : []; - }); + return listBundledChannelLegacyArtifacts( + "legacyStateMigrations", + options, + (entry) => entry.loadLegacyStateMigrationDetector?.(), + (plugin) => plugin.lifecycle?.detectLegacyStateMigrations, + ); } export function getBundledChannelAccountInspector( id: ChannelId, ): NonNullable | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return getBundledChannelAccountInspectorForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("accountInspector", id, rootScope, loadContext); } export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return getBundledChannelPluginForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("plugin", id, rootScope, loadContext); } export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - return getBundledChannelSecretsForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("secrets", id, rootScope, loadContext); } export function getBundledChannelSetupPlugin( @@ -936,7 +755,7 @@ export function getBundledChannelSetupPlugin( env: NodeJS.ProcessEnv = process.env, ): ChannelPlugin | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(env); - return getBundledChannelSetupPluginForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("setupPlugin", id, rootScope, loadContext); } export function getBundledChannelSetupSecrets( @@ -944,13 +763,17 @@ export function getBundledChannelSetupSecrets( env: NodeJS.ProcessEnv = process.env, ): ChannelPlugin["secrets"] | undefined { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(env); - return getBundledChannelSetupSecretsForRoot(id, rootScope, loadContext); + return getBundledChannelArtifactForRoot("setupSecrets", id, rootScope, loadContext); } export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): void { const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope(); - const setter = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry - .setChannelRuntime; + const setter = getBundledChannelArtifactForRoot( + "entry", + id, + rootScope, + loadContext, + )?.setChannelRuntime; if (!setter) { throw new Error(`missing bundled channel runtime setter: ${id}`); } diff --git a/src/channels/plugins/catalog.test.ts b/src/channels/plugins/catalog.test.ts index caf77cab1507..e8fa92947905 100644 --- a/src/channels/plugins/catalog.test.ts +++ b/src/channels/plugins/catalog.test.ts @@ -29,7 +29,12 @@ afterEach(() => { } }); -function writeChannelCatalog(catalogPath: string, id: string, label: string): void { +function writeChannelCatalog( + catalogPath: string, + id: string, + label: string, + defaultChoice?: string, +): void { fs.mkdirSync(path.dirname(catalogPath), { recursive: true }); fs.writeFileSync( catalogPath, @@ -39,7 +44,7 @@ function writeChannelCatalog(catalogPath: string, id: string, label: string): vo name: `@example/${id}`, openclaw: { channel: { id, label, selectionLabel: label, docsPath: `/channels/${id}`, blurb: id }, - install: { npmSpec: `@example/${id}` }, + install: { npmSpec: `@example/${id}`, ...(defaultChoice ? { defaultChoice } : {}) }, }, }, ], @@ -105,6 +110,24 @@ describe("channel plugin catalog", () => { })?.origin, ).toBe("bundled"); }); + + it.each(["__proto__", "constructor", "toString"])( + "rejects inherited install default choice %s from external catalog input", + (defaultChoice) => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-catalog-choice-")); + tempDirs.push(root); + const catalogPath = path.join(root, "catalog.json"); + writeChannelCatalog(catalogPath, "unsafe-choice", "Unsafe Choice", defaultChoice); + + const entry = getChannelPluginCatalogEntry("unsafe-choice", { + catalogPaths: [catalogPath], + workspaceDir: root, + env: {}, + }); + expect(entry?.install.defaultChoice).toBe("npm"); + }, + ); + it("reloads external catalog entries after the explicit plugin metadata lifecycle reset", () => { const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-channel-external-catalog-")); tempDirs.push(root); diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index b819fbfbca0b..48f65b213221 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -79,28 +79,23 @@ const ORIGIN_PRIORITY: Record = { bundled: 3, }; -function shouldExcludeCatalogOrigin(options: CatalogOptions, origin: PluginOrigin): boolean { - if (options.excludeWorkspace && origin === "workspace") { - return true; - } - return options.excludeOrigins?.includes(origin) ?? false; -} - -function shouldExcludeCatalogPlugin( +function shouldExcludeCatalogEntry( options: CatalogOptions, pluginId?: string, origin?: PluginOrigin, ): boolean { const normalizedPluginId = normalizeOptionalString(pluginId); - if (!normalizedPluginId) { - return false; - } return ( - options.excludePluginRefs?.some( - (entry) => - entry.pluginId === normalizedPluginId && - (entry.origin === undefined || entry.origin === origin), - ) ?? false + (options.excludeWorkspace === true && origin === "workspace") || + (origin !== undefined && (options.excludeOrigins?.includes(origin) ?? false)) || + Boolean( + normalizedPluginId && + options.excludePluginRefs?.some( + (entry) => + entry.pluginId === normalizedPluginId && + (entry.origin === undefined || entry.origin === origin), + ), + ) ); } @@ -115,47 +110,21 @@ type ExternalCatalogEntry = { const ENV_CATALOG_PATHS = ["OPENCLAW_PLUGIN_CATALOG_PATHS", "OPENCLAW_MPM_CATALOG_PATHS"]; const OFFICIAL_CHANNEL_CATALOG_RELATIVE_PATH = path.join("dist", "channel-catalog.json"); -const officialCatalogEntriesByPath = new Map(); -const externalCatalogEntriesByPath = new Map(); +const catalogEntriesByPath = new Map(); -registerPluginMetadataProcessMemoLifecycleClear(() => { - officialCatalogEntriesByPath.clear(); - externalCatalogEntriesByPath.clear(); -}); +registerPluginMetadataProcessMemoLifecycleClear(() => catalogEntriesByPath.clear()); type ManifestKey = typeof MANIFEST_KEY; function parseCatalogEntries(raw: unknown): ExternalCatalogEntry[] { - if (Array.isArray(raw)) { - return raw.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); - } - if (!isRecord(raw)) { - return []; - } - const list = raw.entries ?? raw.packages ?? raw.plugins; - if (!Array.isArray(list)) { - return []; - } - return list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)); -} - -function splitEnvPaths(value: string): string[] { - const trimmed = value.trim(); - if (!trimmed) { - return []; - } - return normalizeStringEntries( - trimmed.split(/[;,]/g).flatMap((chunk) => chunk.split(path.delimiter)), - ); -} - -function resolveDefaultCatalogPaths(env: NodeJS.ProcessEnv): string[] { - const configDir = resolveConfigDir(env); - return [ - path.join(configDir, "mpm", "plugins.json"), - path.join(configDir, "mpm", "catalog.json"), - path.join(configDir, "plugins", "catalog.json"), - ]; + const list = Array.isArray(raw) + ? raw + : isRecord(raw) + ? (raw.entries ?? raw.packages ?? raw.plugins) + : undefined; + return Array.isArray(list) + ? list.filter((entry): entry is ExternalCatalogEntry => isRecord(entry)) + : []; } function resolveExternalCatalogPaths(options: CatalogOptions): string[] { @@ -165,23 +134,16 @@ function resolveExternalCatalogPaths(options: CatalogOptions): string[] { const env = options.env ?? process.env; for (const key of ENV_CATALOG_PATHS) { const raw = env[key]; - if (raw && raw.trim()) { - return splitEnvPaths(raw); + if (raw?.trim()) { + return normalizeStringEntries( + raw.split(/[;,]/g).flatMap((chunk) => chunk.split(path.delimiter)), + ); } } - return resolveDefaultCatalogPaths(env); -} - -function loadExternalCatalogEntries(options: CatalogOptions): ExternalCatalogEntry[] { - const paths = resolveExternalCatalogPaths(options).map((rawPath) => - resolveUserPath(rawPath, options.env ?? process.env), + const configDir = resolveConfigDir(env); + return ["mpm/plugins.json", "mpm/catalog.json", "plugins/catalog.json"].map((relativePath) => + path.join(configDir, relativePath), ); - return loadCatalogEntriesFromPaths(paths, externalCatalogEntriesByPath); -} - -function readCatalogEntriesFromPath(resolvedPath: string): ExternalCatalogEntry[] | null { - const payload = tryReadJsonSync(resolvedPath); - return payload === null ? null : parseCatalogEntries(payload); } function loadCatalogEntriesFromPaths( @@ -190,19 +152,15 @@ function loadCatalogEntriesFromPaths( ): ExternalCatalogEntry[] { const entries: ExternalCatalogEntry[] = []; for (const resolvedPath of paths) { - if (cache?.has(resolvedPath)) { - const cached = cache.get(resolvedPath); - if (cached) { - entries.push(...cached); - } - continue; + let parsed = cache?.get(resolvedPath); + if (parsed === undefined) { + const payload = tryReadJsonSync(resolvedPath); + parsed = payload === null ? null : parseCatalogEntries(payload); + cache?.set(resolvedPath, parsed); } - const parsed = readCatalogEntriesFromPath(resolvedPath); - cache?.set(resolvedPath, parsed); - if (parsed === null) { - continue; + if (parsed !== null) { + entries.push(...parsed); } - entries.push(...parsed); } return entries; } @@ -232,49 +190,6 @@ function resolveOfficialCatalogPaths(options: CatalogOptions): string[] { return uniqueStrings(candidates); } -function loadOfficialCatalogEntries(options: CatalogOptions): ChannelPluginCatalogEntry[] { - const builtInEntries = listOfficialExternalChannelCatalogEntries(); - const officialPaths = resolveOfficialCatalogPaths(options); - const fileEntries = loadCatalogEntriesFromPaths( - officialPaths, - options.officialCatalogPaths && options.officialCatalogPaths.length > 0 - ? undefined - : officialCatalogEntriesByPath, - ); - return [...builtInEntries, ...fileEntries] - .map((entry) => buildExternalCatalogEntry(entry, { trustedSourceLinkedOfficialInstall: true })) - .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); -} - -function toChannelMeta(params: { - channel: NonNullable; - id: string; -}): ChannelMeta | null { - const label = params.channel.label?.trim(); - if (!label) { - return null; - } - const selectionLabel = params.channel.selectionLabel?.trim() || label; - const detailLabel = params.channel.detailLabel?.trim(); - const docsPath = params.channel.docsPath?.trim() || `/channels/${params.id}`; - const blurb = params.channel.blurb?.trim() || ""; - const systemImage = params.channel.systemImage?.trim(); - - return buildManifestChannelMeta({ - id: params.id, - channel: params.channel, - label, - selectionLabel, - docsPath, - docsLabel: normalizeOptionalString(params.channel.docsLabel), - blurb, - detailLabel, - ...(systemImage ? { systemImage } : {}), - arrayFieldMode: "defined", - selectionDocsPrefixMode: "truthy", - }); -} - function resolveInstallInfo(params: { install?: PluginPackageInstall; packageName?: string; @@ -306,18 +221,17 @@ function resolveInstallInfo(params: { localPath = path.relative(params.workspaceDir, params.packageDir) || undefined; } const requestedDefaultChoice = params.install?.defaultChoice; + const availableChoices = { clawhub: clawhubSpec, npm: npmSpec, local: localPath }; const defaultChoice: NonNullable = - requestedDefaultChoice === "clawhub" && clawhubSpec - ? "clawhub" - : requestedDefaultChoice === "npm" && npmSpec - ? "npm" - : requestedDefaultChoice === "local" && localPath + requestedDefaultChoice && + Object.hasOwn(availableChoices, requestedDefaultChoice) && + availableChoices[requestedDefaultChoice] + ? requestedDefaultChoice + : clawhubSpec + ? "clawhub" + : localPath ? "local" - : clawhubSpec - ? "clawhub" - : localPath - ? "local" - : "npm"; + : "npm"; const install = { ...(localPath ? { localPath } : {}), defaultChoice, @@ -356,28 +270,18 @@ function buildCatalogEntryFromManifest(params: { channel?: PluginPackageChannel; install?: PluginPackageInstall; }): ChannelPluginCatalogEntry | null { - if (!params.channel) { + const channel = params.channel; + const id = channel?.id?.trim(); + const label = channel?.label?.trim(); + if (!channel || !id || !label) { return null; } - const id = params.channel.id?.trim(); - if (!id) { - return null; - } - const meta = toChannelMeta({ channel: params.channel, id }); - if (!meta) { - return null; - } - const install = resolveInstallInfo({ - install: params.install, - packageName: params.packageName, - packageVersion: params.packageVersion, - packageDir: params.packageDir, - workspaceDir: params.workspaceDir, - }); + const install = resolveInstallInfo(params); if (!install) { return null; } const pluginId = normalizeOptionalString(params.pluginId); + const systemImage = channel.systemImage?.trim(); return { id, ...(pluginId ? { pluginId } : {}), @@ -385,8 +289,20 @@ function buildCatalogEntryFromManifest(params: { ...(params.trustedSourceLinkedOfficialInstall ? { trustedSourceLinkedOfficialInstall: true } : {}), - channel: params.channel, - meta, + channel, + meta: buildManifestChannelMeta({ + id, + channel, + label, + selectionLabel: channel.selectionLabel?.trim() || label, + docsPath: channel.docsPath?.trim() || `/channels/${id}`, + docsLabel: normalizeOptionalString(channel.docsLabel), + blurb: channel.blurb?.trim() || "", + detailLabel: channel.detailLabel?.trim(), + ...(systemImage ? { systemImage } : {}), + arrayFieldMode: "defined", + selectionDocsPrefixMode: "truthy", + }), install, installSource: describePluginInstallSource(install, { expectedPackageName: params.packageName, @@ -396,16 +312,14 @@ function buildCatalogEntryFromManifest(params: { function buildExternalCatalogEntry( entry: ExternalCatalogEntry, - options?: { - trustedSourceLinkedOfficialInstall?: boolean; - }, + trustedSourceLinkedOfficialInstall = false, ): ChannelPluginCatalogEntry | null { const manifest = entry[MANIFEST_KEY]; return buildCatalogEntryFromManifest({ pluginId: manifest?.plugin?.id, packageName: entry.name, packageVersion: entry.version, - trustedSourceLinkedOfficialInstall: options?.trustedSourceLinkedOfficialInstall, + trustedSourceLinkedOfficialInstall, channel: manifest?.channel, install: manifest?.install, }); @@ -467,10 +381,7 @@ export function listRawChannelPluginCatalogEntries( }; for (const candidate of manifestEntries) { - if ( - shouldExcludeCatalogOrigin(options, candidate.origin) || - shouldExcludeCatalogPlugin(options, candidate.pluginId, candidate.origin) - ) { + if (shouldExcludeCatalogEntry(options, candidate.pluginId, candidate.origin)) { continue; } const entry = buildCatalogEntryFromManifest({ @@ -488,18 +399,35 @@ export function listRawChannelPluginCatalogEntries( rememberCatalogEntry(entry, ORIGIN_PRIORITY[candidate.origin] ?? 99); } - for (const entry of loadOfficialCatalogEntries(options)) { - rememberCatalogEntry(entry, FALLBACK_CATALOG_PRIORITY); - } + const rememberExternalCatalogEntries = ( + entries: ExternalCatalogEntry[], + priority: number, + trustedSourceLinkedOfficialInstall = false, + ) => { + for (const candidate of entries) { + const entry = buildExternalCatalogEntry(candidate, trustedSourceLinkedOfficialInstall); + if (entry) { + rememberCatalogEntry(entry, priority); + } + } + }; + const officialFileEntries = loadCatalogEntriesFromPaths( + resolveOfficialCatalogPaths(options), + options.officialCatalogPaths?.length ? undefined : catalogEntriesByPath, + ); + rememberExternalCatalogEntries( + [...listOfficialExternalChannelCatalogEntries(), ...officialFileEntries], + FALLBACK_CATALOG_PRIORITY, + true, + ); - const externalEntries = loadExternalCatalogEntries(options) - .map((entry) => buildExternalCatalogEntry(entry)) - .filter((entry): entry is ChannelPluginCatalogEntry => Boolean(entry)); - for (const entry of externalEntries) { - // External catalogs are the supported override seam for shipped fallback - // metadata, but discovered plugins should still win when they are present. - rememberCatalogEntry(entry, EXTERNAL_CATALOG_PRIORITY); - } + const externalCatalogPaths = resolveExternalCatalogPaths(options).map((rawPath) => + resolveUserPath(rawPath, options.env ?? process.env), + ); + const externalEntries = loadCatalogEntriesFromPaths(externalCatalogPaths, catalogEntriesByPath); + // External catalogs are the supported override seam for shipped fallback + // metadata, but discovered plugins should still win when they are present. + rememberExternalCatalogEntries(externalEntries, EXTERNAL_CATALOG_PRIORITY); return Array.from(resolved.values()) .map(({ entry }) => entry) diff --git a/src/channels/plugins/read-only.test.ts b/src/channels/plugins/read-only.test.ts index 832cf280f47e..495aff01ac2c 100644 --- a/src/channels/plugins/read-only.test.ts +++ b/src/channels/plugins/read-only.test.ts @@ -1,7 +1,6 @@ // Read-only channel tests cover read-only plugin registration and runtime behavior. import fs from "node:fs"; import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { afterAll, afterEach, describe, expect, it, vi } from "vitest"; import { cleanupPluginLoaderFixturesForTest, @@ -17,7 +16,6 @@ import { createTestRegistry, } from "../../test-utils/channel-plugins.js"; import { - listPluginLoaderModuleCandidateUrls, listReadOnlyChannelPluginsForConfig, resolveReadOnlyChannelPluginsForConfig, } from "./read-only.js"; @@ -54,11 +52,6 @@ function createExternalChannelTestConfig(params: { }; } -function modulePathEndsWith(modulePath: string, suffix: string): boolean { - const normalized = modulePath.startsWith("file:") ? fileURLToPath(modulePath) : modulePath; - return normalized.replace(/\\/g, "/").endsWith(suffix); -} - function expectRecordFields(record: unknown, expected: Record) { if (!record || typeof record !== "object") { throw new Error("Expected record"); @@ -82,96 +75,6 @@ vi.mock("../../plugins/bundled-dir.js", async (importOriginal) => { vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => { const actual = await importOriginal(); - const { createRequire } = await import("node:module"); - const require = createRequire(import.meta.url); - - type LoaderConfig = { - plugins?: { - load?: { paths?: unknown }; - }; - }; - type LoaderParams = { - config?: LoaderConfig; - onlyPluginIds?: readonly string[]; - workspaceDir?: string; - }; - - function readJson(filePath: string): unknown { - return JSON.parse(fs.readFileSync(filePath, "utf-8")); - } - - function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); - } - - function listCandidatePluginDirs(params: LoaderParams): string[] { - const paths = params.config?.plugins?.load?.paths; - const explicitPaths = Array.isArray(paths) - ? paths.filter((entry): entry is string => typeof entry === "string") - : []; - const workspaceExtensionsDir = params.workspaceDir - ? path.join(params.workspaceDir, ".openclaw", "extensions") - : undefined; - if (!workspaceExtensionsDir || !fs.existsSync(workspaceExtensionsDir)) { - return explicitPaths; - } - return explicitPaths.concat( - fs - .readdirSync(workspaceExtensionsDir, { withFileTypes: true }) - .filter((entry) => entry.isDirectory()) - .map((entry) => path.join(workspaceExtensionsDir, entry.name)), - ); - } - - function loadOpenClawPlugins(params: LoaderParams) { - const onlyPluginIds = new Set(params.onlyPluginIds ?? []); - const diagnostics: Array<{ - level: "error"; - pluginId: string; - source: string; - message: string; - }> = []; - const channelSetups = listCandidatePluginDirs(params).flatMap((pluginDir) => { - const manifestPath = path.join(pluginDir, "openclaw.plugin.json"); - const packagePath = path.join(pluginDir, "package.json"); - if (!fs.existsSync(manifestPath) || !fs.existsSync(packagePath)) { - return []; - } - const manifest = readJson(manifestPath); - if (!isRecord(manifest) || typeof manifest.id !== "string") { - return []; - } - if (onlyPluginIds.size > 0 && !onlyPluginIds.has(manifest.id)) { - return []; - } - const packageJson = readJson(packagePath); - const openclaw = isRecord(packageJson) ? packageJson.openclaw : undefined; - const setupEntry = isRecord(openclaw) ? openclaw.setupEntry : undefined; - if (typeof setupEntry !== "string") { - return []; - } - const setupPath = path.join(pluginDir, setupEntry); - let setupModule: unknown; - try { - setupModule = require(setupPath); - } catch (error) { - diagnostics.push({ - level: "error", - pluginId: manifest.id, - source: setupPath, - message: `failed to load setup entry: ${String(error)}`, - }); - return []; - } - const entry = ((setupModule as { default?: unknown }).default ?? setupModule) as { - plugin?: unknown; - }; - const plugin = entry.plugin; - return plugin ? [{ pluginId: manifest.id, plugin }] : []; - }); - return { channelSetups, diagnostics }; - } - return { ...actual, getCachedPluginModuleLoader: ((params) => { @@ -179,16 +82,7 @@ vi.mock("../../plugins/plugin-module-loader-cache.js", async (importOriginal) => modulePath: params.modulePath, tryNative: params.tryNative, }); - const actualLoader = actual.getCachedPluginModuleLoader(params); - return ((modulePath: string) => { - if ( - modulePathEndsWith(modulePath, "/plugins/loader.js") || - modulePathEndsWith(modulePath, "/plugins/loader.ts") - ) { - return { loadOpenClawPlugins }; - } - return actualLoader(modulePath); - }) as ReturnType; + return actual.getCachedPluginModuleLoader(params); }) satisfies typeof actual.getCachedPluginModuleLoader, }; }); @@ -510,20 +404,6 @@ afterAll(() => { }); describe("listReadOnlyChannelPluginsForConfig", () => { - it("keeps built plugin loader candidates inside the installed package dist root", () => { - const packageRoot = path.join(makeTempDir(), "node_modules", "openclaw"); - const importerPath = path.join(packageRoot, "dist", "read-only-B4EkEtUx.js"); - const candidates = listPluginLoaderModuleCandidateUrls(pathToFileURL(importerPath).href).map( - (candidate) => fileURLToPath(candidate), - ); - - expect(candidates).toEqual([ - path.join(packageRoot, "dist", "plugins", "loader.js"), - path.join(packageRoot, "dist", "plugins", "build-smoke-entry.js"), - ]); - expect(candidates).not.toContain(path.join(packageRoot, "..", "plugins", "loader.js")); - }); - it("uses package channel metadata without loading setup or full runtime", () => { const { pluginDir, fullMarker, setupMarker } = writeExternalSetupChannelPlugin(); const plugins = listReadOnlyChannelPluginsForConfig( @@ -551,14 +431,10 @@ describe("listReadOnlyChannelPluginsForConfig", () => { ); expectExternalChatSetupOnlyPluginLoaded({ plugins, setupMarker, fullMarker }); - expect( - moduleLoaderParams.some( - (entry) => - entry.tryNative === true && - (modulePathEndsWith(entry.modulePath, "/plugins/loader.js") || - modulePathEndsWith(entry.modulePath, "/plugins/loader.ts")), - ), - ).toBe(true); + expect(moduleLoaderParams).toContainEqual({ + modulePath: path.join(pluginDir, "setup-entry.cjs"), + tryNative: true, + }); }); it("uses activation source config to discover channel setup metadata after secret stripping", () => { diff --git a/src/channels/plugins/read-only.ts b/src/channels/plugins/read-only.ts index 7dd48df94b10..803add7768b7 100644 --- a/src/channels/plugins/read-only.ts +++ b/src/channels/plugins/read-only.ts @@ -4,8 +4,6 @@ * Builds lightweight channel plugin views from config, manifests, and setup metadata. */ import { createHash } from "node:crypto"; -import path from "node:path"; -import { fileURLToPath, pathToFileURL } from "node:url"; import { sortUniqueStrings, uniqueStrings, @@ -27,7 +25,6 @@ import { resolveSetupChannelRegistration, } from "../../plugins/loader-channel-setup.js"; import type { PluginManifestRecord } from "../../plugins/manifest-registry.js"; -import type { PluginDiagnostic } from "../../plugins/manifest-types.js"; import { registerPluginMetadataProcessMemoLifecycleClear } from "../../plugins/plugin-metadata-lifecycle.js"; import { resolvePluginMetadataSnapshot } from "../../plugins/plugin-metadata-snapshot.js"; import { @@ -52,92 +49,9 @@ import { import { listChannelPlugins } from "./registry.js"; import type { ChannelPlugin } from "./types.plugin.js"; -const SOURCE_PLUGIN_LOADER_MODULE_CANDIDATES = [ - "../../plugins/loader.js", - "../../plugins/loader.ts", -] as const; -const BUILT_PLUGIN_LOADER_MODULE_CANDIDATES = [ - "plugins/loader.js", - "plugins/build-smoke-entry.js", -] as const; const moduleLoaders: PluginModuleLoaderCache = new Map(); const log = createSubsystemLogger("channels"); -type PluginLoaderModule = { - loadOpenClawPlugins: (params: { - config: OpenClawConfig; - activationSourceConfig?: OpenClawConfig; - env?: NodeJS.ProcessEnv; - workspaceDir?: string; - cache?: boolean; - activate?: boolean; - includeSetupOnlyChannelPlugins?: boolean; - forceSetupOnlyChannelPlugins?: boolean; - requireSetupEntryForSetupOnlyChannelPlugins?: boolean; - onlyPluginIds?: readonly string[]; - }) => { - channelSetups: Iterable<{ - pluginId: string; - plugin: ChannelPlugin; - }>; - diagnostics?: readonly PluginDiagnostic[]; - }; -}; - -let pluginLoaderModule: PluginLoaderModule | undefined; - -function listBuiltPluginLoaderModuleCandidateUrls(importerUrl: string): URL[] { - let importerPath: string; - try { - importerPath = fileURLToPath(importerUrl); - } catch { - return []; - } - const distMarker = `${path.sep}dist${path.sep}`; - const distMarkerIndex = importerPath.lastIndexOf(distMarker); - if (distMarkerIndex < 0) { - return []; - } - // Bundled read-only chunks live under dist/ with hashed names. Source-relative - // ../../plugins candidates would escape the installed openclaw package there. - const distRoot = importerPath.slice(0, distMarkerIndex + distMarker.length - 1); - return BUILT_PLUGIN_LOADER_MODULE_CANDIDATES.map((candidate) => - pathToFileURL(path.join(distRoot, candidate)), - ); -} - -export function listPluginLoaderModuleCandidateUrls(importerUrl = import.meta.url): URL[] { - const builtCandidates = listBuiltPluginLoaderModuleCandidateUrls(importerUrl); - if (builtCandidates.length > 0) { - return builtCandidates; - } - return SOURCE_PLUGIN_LOADER_MODULE_CANDIDATES.map((candidate) => new URL(candidate, importerUrl)); -} - -function loadPluginLoaderModule(): PluginLoaderModule { - if (pluginLoaderModule) { - return pluginLoaderModule; - } - for (const candidate of listPluginLoaderModuleCandidateUrls()) { - const modulePath = fileURLToPath(candidate); - try { - const moduleLoader = getCachedPluginModuleLoader({ - cache: moduleLoaders, - modulePath, - importerUrl: import.meta.url, - preferBuiltDist: true, - loaderFilename: import.meta.url, - tryNative: true, - }); - pluginLoaderModule = moduleLoader(modulePath) as PluginLoaderModule; - return pluginLoaderModule; - } catch { - // Try built/runtime source candidates in order. - } - } - throw new Error("Could not load plugin runtime loader for channel setup fallback."); -} - type ReadOnlyChannelPluginOptions = { env?: NodeJS.ProcessEnv; stateDir?: string; @@ -549,7 +463,7 @@ function loadSetupChannelPluginFromManifestRecord(params: { ) { return {}; } - return { plugin: cloneChannelPluginForChannelId(registration.plugin, params.channelId) }; + return { plugin: registration.plugin }; } catch (error) { const detail = formatErrorMessage(error); log.warn(`[channels] failed to load channel setup ${params.record.id}: ${detail}`); @@ -564,40 +478,6 @@ function loadSetupChannelPluginFromManifestRecord(params: { } } -function collectChannelPluginLoadFailuresFromDiagnostics(params: { - diagnostics: readonly PluginDiagnostic[] | undefined; - records: readonly PluginManifestRecord[]; - channelIds: readonly string[]; -}): ReadOnlyChannelPluginLoadFailure[] { - if (!params.diagnostics?.length || params.channelIds.length === 0) { - return []; - } - const configuredChannelIds = new Set(params.channelIds); - const recordsByPluginId = new Map(params.records.map((record) => [record.id, record] as const)); - const failures: ReadOnlyChannelPluginLoadFailure[] = []; - for (const diagnostic of params.diagnostics) { - if (diagnostic.level !== "error" || !diagnostic.pluginId) { - continue; - } - const record = recordsByPluginId.get(diagnostic.pluginId); - if (!record) { - continue; - } - for (const channelId of record.channels) { - if (!configuredChannelIds.has(channelId)) { - continue; - } - failures.push({ - channelId, - pluginId: record.id, - source: diagnostic.source, - message: diagnostic.message, - }); - } - } - return failures; -} - function rebindChannelPluginConfig( config: ChannelPlugin["config"], sourceChannelId: string, @@ -727,43 +607,6 @@ function cloneChannelPluginForChannelId(plugin: ChannelPlugin, channelId: string }; } -function addSetupChannelPlugins( - byId: Map, - setups: Iterable<{ - pluginId: string; - plugin: ChannelPlugin; - }>, - options: { - ownedChannelIdsByPluginId: ReadonlyMap; - ownedMissingChannelIdsByPluginId: ReadonlyMap; - }, -): void { - for (const setup of setups) { - const ownedMissingChannelIds = options.ownedMissingChannelIdsByPluginId - .get(setup.pluginId) - ?.filter(isSafeManifestChannelId); - if (!ownedMissingChannelIds || ownedMissingChannelIds.length === 0) { - continue; - } - const ownedChannelIds = (options.ownedChannelIdsByPluginId.get(setup.pluginId) ?? []).filter( - isSafeManifestChannelId, - ); - if (setup.plugin.id !== setup.pluginId && !ownedChannelIds.includes(setup.plugin.id)) { - continue; - } - addChannelPlugins( - byId, - ownedMissingChannelIds.map((channelId) => - cloneChannelPluginForChannelId(setup.plugin, channelId), - ), - { - onlyIds: new Set(ownedMissingChannelIds), - allowOverwrite: false, - }, - ); - } -} - function addManifestChannelPlugins( byId: Map, records: readonly PluginManifestRecord[], @@ -930,7 +773,9 @@ export function resolveReadOnlyChannelPluginsForConfig( const bundledSetupPlugin = setupResults.map((result) => result.plugin).find((plugin) => plugin) ?? getBundledChannelSetupPlugin(channelId, env); - addChannelPlugins(byId, [bundledSetupPlugin]); + addChannelPlugins(byId, [ + bundledSetupPlugin && cloneChannelPluginForChannelId(bundledSetupPlugin, channelId), + ]); } } @@ -962,45 +807,41 @@ export function resolveReadOnlyChannelPluginsForConfig( }); if (externalPluginIds.length > 0) { const externalPluginIdSet = new Set(externalPluginIds); - const ownedChannelIdsByPluginId = new Map( - externalManifestRecords - .filter((record) => externalPluginIdSet.has(record.id)) - .map((record) => [record.id, record.channels] as const), - ); - if (missingConfiguredChannelIds.length > 0 && options.includeSetupFallbackPlugins === true) { + if (options.includeSetupFallbackPlugins === true) { const missingChannelIdSet = new Set(missingConfiguredChannelIds); - const ownedMissingChannelIdsByPluginId = new Map( - [...ownedChannelIdsByPluginId].map( - ([pluginId, channelIds]) => - [ - pluginId, - channelIds.filter((channelId) => missingChannelIdSet.has(channelId)), - ] as const, - ), - ); - const registry = loadPluginLoaderModule().loadOpenClawPlugins({ - config: cfg, - activationSourceConfig: options.activationSourceConfig ?? cfg, - env, - workspaceDir, - cache: false, - activate: false, - includeSetupOnlyChannelPlugins: true, - forceSetupOnlyChannelPlugins: true, - requireSetupEntryForSetupOnlyChannelPlugins: true, - onlyPluginIds: externalPluginIds, - }); - loadFailures.push( - ...collectChannelPluginLoadFailuresFromDiagnostics({ - diagnostics: registry.diagnostics, - records: externalManifestRecords, - channelIds: missingConfiguredChannelIds, - }), - ); - addSetupChannelPlugins(byId, registry.channelSetups, { - ownedChannelIdsByPluginId, - ownedMissingChannelIdsByPluginId, - }); + for (const record of externalManifestRecords) { + if (!externalPluginIdSet.has(record.id) || !record.setupSource) { + continue; + } + const ownedMissingChannelIds = record.channels.filter( + (channelId) => missingChannelIdSet.has(channelId) && !byId.has(channelId), + ); + const firstChannelId = ownedMissingChannelIds[0]; + if (!firstChannelId) { + continue; + } + const setupResult = loadSetupChannelPluginFromManifestRecord({ + record, + channelId: firstChannelId, + }); + const failure = setupResult.failure; + if (failure) { + loadFailures.push( + ...ownedMissingChannelIds.map((channelId) => ({ ...failure, channelId })), + ); + continue; + } + const plugin = setupResult.plugin; + if (plugin) { + addChannelPlugins( + byId, + ownedMissingChannelIds.map((channelId) => + cloneChannelPluginForChannelId(plugin, channelId), + ), + { allowOverwrite: false }, + ); + } + } } const externalManifestMissingChannelIds = missingConfiguredChannelIds.filter( (channelId) => !byId.has(channelId), diff --git a/src/cli/config-output-mode.ts b/src/cli/config-output-mode.ts index b67fda4d8116..cb9f1d85599d 100644 --- a/src/cli/config-output-mode.ts +++ b/src/cli/config-output-mode.ts @@ -43,9 +43,10 @@ function resolveConfigSubcommand(argv: readonly string[]): string | null { return null; } -/** Config get reserves stdout for the requested value, including bare scalar output. */ +/** Config values, paths, and schemas reserve stdout for machine-consumed output. */ export function isConfigMachineOutput(argv: readonly string[]): boolean { - return resolveConfigSubcommand(argv) === "get"; + const subcommand = resolveConfigSubcommand(argv); + return subcommand === "get" || subcommand === "file" || subcommand === "schema"; } /** Config set uses --json as a parser alias except when dry-run emits a JSON report. */ diff --git a/src/cli/machine-output-modes.test.ts b/src/cli/machine-output-modes.test.ts index 258d4b8c6c5c..020f4b068d28 100644 --- a/src/cli/machine-output-modes.test.ts +++ b/src/cli/machine-output-modes.test.ts @@ -67,23 +67,21 @@ describe("built-in machine-output resolvers", () => { ).toBe(true); }); - it("reserves raw cron scratch and config get output", () => { + it("reserves raw cron scratch output", () => { expect(isCronMachineOutput(["node", "openclaw", "cron", "scratch", "job"])).toBe(true); - expect(isConfigMachineOutput(["node", "openclaw", "config", "get", "gateway.port"])).toBe(true); + }); + + it.each(["get", "file", "schema"])("reserves config %s machine output", (subcommand) => { + expect(isConfigMachineOutput(["node", "openclaw", "config", subcommand])).toBe(true); expect( - isConfigMachineOutput([ - "node", - "openclaw", - "config", - "--section", - "agents", - "get", - "gateway.port", - ]), + isConfigMachineOutput(["node", "openclaw", "config", "--section", "agents", subcommand]), ).toBe(true); }); it("treats config set --json as parse-only except for JSON dry-run reports", () => { + expect(isConfigMachineOutput(["node", "openclaw", "config", "set", "gateway.port"])).toBe( + false, + ); expect( isConfigSetJsonParseOnly([ "node", diff --git a/src/cli/one-shot-exit.test.ts b/src/cli/one-shot-exit.test.ts index 74d541a92951..be51b0228487 100644 --- a/src/cli/one-shot-exit.test.ts +++ b/src/cli/one-shot-exit.test.ts @@ -296,4 +296,86 @@ describe("one-shot CLI exit", () => { expect(result.stderr).toBe(""); expect(result.stdout).toHaveLength(payloadBytes); }); + + it.each([ + { name: "deferred hooks success", exitCode: 0, explicitRequest: true }, + { name: "deferred hooks failure", exitCode: 1, explicitRequest: true }, + { name: "automatic macOS system-CA success", exitCode: 0, explicitRequest: false }, + ])("keeps real dual-TTY JSON clean for $name", ({ exitCode, explicitRequest }) => { + const env = { ...process.env }; + delete env.VITEST; + delete env.VITEST_POOL_ID; + delete env.VITEST_WORKER_ID; + const oneShotExitUrl = new URL("./one-shot-exit.ts", import.meta.url).href; + const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; + const loggingStateUrl = new URL("../logging/state.ts", import.meta.url).href; + const script = ` + import { requestExitAfterOneShotOutput, runCliWithExitFinalization } from ${JSON.stringify(oneShotExitUrl)}; + import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; + import { loggingState } from ${JSON.stringify(loggingStateUrl)}; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + loggingState.forceConsoleToStderr = true; + await runCliWithExitFinalization({ + run: async () => { + defaultRuntime.writeStdout(JSON.stringify({ ok: ${exitCode === 0} })); + ${explicitRequest ? `requestExitAfterOneShotOutput(defaultRuntime, ${exitCode});` : ""} + }, + onError: (error) => { throw error; }, + ${explicitRequest ? "" : 'env: { NODE_USE_SYSTEM_CA: "1" }, execArgv: [], platform: "darwin", markers: {},'} + }); + `; + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { encoding: "utf8", env, timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(exitCode); + expect(result.signal).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual({ ok: exitCode === 0 }); + expect(result.stderr).toContain("\x1b[?25h"); + }); + + it.each([ + { name: "fatal unhandled rejection", errorCode: "ERR_OUT_OF_MEMORY", exitCode: 1 }, + { name: "invalid configuration rejection", errorCode: "INVALID_CONFIG", exitCode: 78 }, + ])("keeps real dual-TTY JSON clean after $name", ({ errorCode, exitCode }) => { + const env = { ...process.env }; + delete env.VITEST; + delete env.VITEST_POOL_ID; + delete env.VITEST_WORKER_ID; + const runtimeUrl = new URL("../runtime.ts", import.meta.url).href; + const loggingStateUrl = new URL("../logging/state.ts", import.meta.url).href; + const unhandledRejectionsUrl = new URL("../infra/unhandled-rejections.ts", import.meta.url) + .href; + const script = ` + import { defaultRuntime } from ${JSON.stringify(runtimeUrl)}; + import { loggingState } from ${JSON.stringify(loggingStateUrl)}; + import { installUnhandledRejectionHandler } from ${JSON.stringify(unhandledRejectionsUrl)}; + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + loggingState.forceConsoleToStderr = true; + installUnhandledRejectionHandler(); + defaultRuntime.writeJson({ ok: false }); + const error = Object.assign(new Error("expected fatal test"), { + code: ${JSON.stringify(errorCode)}, + }); + process.emit("unhandledRejection", error, Promise.resolve()); + `; + + const result = spawnSync( + process.execPath, + ["--import", "tsx", "--input-type=module", "--eval", script], + { encoding: "utf8", env, timeout: 30_000 }, + ); + + expect(result.error).toBeUndefined(); + expect(result.status).toBe(exitCode); + expect(result.signal).toBeNull(); + expect(JSON.parse(result.stdout)).toEqual({ ok: false }); + expect(result.stderr).toContain("\x1b[?25h"); + }); }); diff --git a/src/cli/plugins-cli.policy.test.ts b/src/cli/plugins-cli.policy.test.ts index 87fc744612a3..14cc09d25a50 100644 --- a/src/cli/plugins-cli.policy.test.ts +++ b/src/cli/plugins-cli.policy.test.ts @@ -118,7 +118,7 @@ describe("plugins cli policy mutations", () => { plugins: { allow: ["other-plugin"] }, reason: "blocked by allowlist", }, - ])("does not mutate plugin state when $policy blocks enablement", async ({ plugins, reason }) => { + ])("fails without mutations when $policy blocks enablement", async ({ plugins, reason }) => { const sourceConfig = { plugins } as OpenClawConfig; loadConfig.mockReturnValue(sourceConfig); enablePluginInConfig.mockReturnValue({ @@ -129,11 +129,13 @@ describe("plugins cli policy mutations", () => { }); mockPluginRegistry(["alpha"]); - await runPluginsCommand(["plugins", "enable", "alpha"]); + await expect(runPluginsCommand(["plugins", "enable", "alpha"])).rejects.toThrow("__exit__:1"); + expect(replaceConfigFile).not.toHaveBeenCalled(); expect(writeConfigFile).not.toHaveBeenCalled(); expect(refreshPluginRegistry).not.toHaveBeenCalled(); - expect(runtimeLogs).toContain(`Plugin "alpha" could not be enabled (${reason}).`); + expect(runtimeErrors).toContain(`Plugin "alpha" could not be enabled (${reason}).`); + expect(runtimeLogs).not.toContain(`Plugin "alpha" could not be enabled (${reason}).`); }); it("refuses plugin enablement in Nix mode before config mutation", async () => { diff --git a/src/cli/plugins-cli.runtime.ts b/src/cli/plugins-cli.runtime.ts index e52f5dfa4679..1d1c44761cb7 100644 --- a/src/cli/plugins-cli.runtime.ts +++ b/src/cli/plugins-cli.runtime.ts @@ -205,12 +205,10 @@ async function runPluginsEnableCommandUnlocked(idInput: string): Promise { }); // A blocked request must not displace the active slot or rewrite persisted state. if (!enableResult.enabled) { - defaultRuntime.log( - theme.warn( - `Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`, - ), + defaultRuntime.error( + `Plugin "${id}" could not be enabled (${enableResult.reason ?? "unknown reason"}).`, ); - return; + return defaultRuntime.exit(1); } const { applySlotSelectionForPlugin } = await loadPluginSlotSelection(); diff --git a/src/cli/plugins-list-command.test.ts b/src/cli/plugins-list-command.test.ts index 40b8e7bdf0f2..dd59ef998be9 100644 --- a/src/cli/plugins-list-command.test.ts +++ b/src/cli/plugins-list-command.test.ts @@ -1,5 +1,6 @@ // Plugins list command tests cover plugin list command execution and output. import { afterEach, describe, expect, it, vi } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; import type { OutputRuntimeEnv } from "../runtime.js"; function createJsonRuntime(writes: unknown[]): OutputRuntimeEnv { @@ -14,6 +15,65 @@ function createJsonRuntime(writes: unknown[]): OutputRuntimeEnv { }; } +type SnapshotPlugin = { + id: string; + enabled: boolean; + commands?: string[]; + agentHarnessIds?: string[]; +}; + +function mockPluginListSnapshot(plugins: SnapshotPlugin[], config: OpenClawConfig = {}): void { + vi.doMock("../config/config.js", () => ({ + getRuntimeConfig: () => config, + })); + vi.doMock("../plugins/status-snapshot.js", () => ({ + buildPluginRegistrySnapshotReport: () => ({ + workspaceDir: "/workspace", + registrySource: "config", + registryDiagnostics: [], + plugins, + diagnostics: [], + }), + })); +} + +function mockHumanListModules(importedModules: string[] = []): void { + vi.doMock("../plugins/source-display.js", () => { + importedModules.push("source-display"); + return { + formatPluginSourceForTable: vi.fn(), + resolvePluginSourceRoots: vi.fn(), + }; + }); + vi.doMock("../../packages/terminal-core/src/table.js", () => { + importedModules.push("table"); + return { + getTerminalTableWidth: vi.fn(), + renderTable: vi.fn(), + }; + }); + vi.doMock("../../packages/terminal-core/src/theme.js", () => { + importedModules.push("theme"); + return { + theme: { + muted: (value: string) => value, + }, + }; + }); + vi.doMock("./command-format.js", () => { + importedModules.push("command-format"); + return { + formatCliCommand: (value: string) => `formatted(${value})`, + }; + }); + vi.doMock("./plugins-list-format.js", () => { + importedModules.push("plugins-list-format"); + return { + formatPluginLine: vi.fn(), + }; + }); +} + describe("runPluginsListCommand", () => { afterEach(() => { vi.doUnmock("../config/config.js"); @@ -22,6 +82,8 @@ describe("runPluginsListCommand", () => { vi.doUnmock("../plugins/source-display.js"); vi.doUnmock("../terminal/table.js"); vi.doUnmock("../terminal/theme.js"); + vi.doUnmock("../../packages/terminal-core/src/table.js"); + vi.doUnmock("../../packages/terminal-core/src/theme.js"); vi.doUnmock("./command-format.js"); vi.doUnmock("./plugins-list-format.js"); vi.resetModules(); @@ -114,4 +176,93 @@ describe("runPluginsListCommand", () => { }, ]); }); + + it.each([ + { label: "normal", options: { enabled: true } }, + { label: "verbose", options: { enabled: true, verbose: true } }, + ])( + "explains an empty enabled-only $label list when plugins are installed", + async ({ options }) => { + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }]); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand(options, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No enabled plugins found. Run formatted(openclaw plugins list) to inspect installed plugins.", + ]); + }, + ); + + it.each([ + { label: "normal", options: { enabled: true } }, + { label: "verbose", options: { enabled: true, verbose: true } }, + ])("explains a globally disabled $label plugin inventory", async ({ options }) => { + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }], { + plugins: { enabled: false }, + }); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand(options, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No enabled plugins found. Plugins are globally disabled. Run formatted(openclaw plugins list) to inspect installed plugins.", + ]); + }); + + it.each([ + { label: "denylist", config: { plugins: { deny: ["disabled-plugin"] } } }, + { + label: "allowlist", + config: { plugins: { allow: ["allowed-plugin"] } }, + }, + ])("does not suggest a blocked mutation for a $label", async ({ config }) => { + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }], config); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand({ enabled: true }, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No enabled plugins found. Run formatted(openclaw plugins list) to inspect installed plugins.", + ]); + }); + + it("keeps install guidance when an enabled-only list has no installed plugins", async () => { + mockPluginListSnapshot([]); + mockHumanListModules(); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand({ enabled: true }, createJsonRuntime(writes)); + + expect(writes).toEqual([ + "No plugins found. Run formatted(openclaw plugins install ) to add one, or formatted(openclaw plugins list --json) to inspect raw discovery state.", + ]); + }); + + it("keeps empty enabled-only JSON lazy when every installed plugin is disabled", async () => { + const importedHumanModules: string[] = []; + mockPluginListSnapshot([{ id: "disabled-plugin", enabled: false }]); + mockHumanListModules(importedHumanModules); + const { runPluginsListCommand } = await import("./plugins-list-command.js"); + const writes: unknown[] = []; + + await runPluginsListCommand({ enabled: true, json: true }, createJsonRuntime(writes)); + + expect(importedHumanModules).toEqual([]); + expect(writes).toEqual([ + { + workspaceDir: "/workspace", + registry: { source: "config", diagnostics: [] }, + plugins: [], + diagnostics: [], + }, + ]); + }); }); diff --git a/src/cli/plugins-list-command.ts b/src/cli/plugins-list-command.ts index 8ab4c35fd85c..9cbbf05101b1 100644 --- a/src/cli/plugins-list-command.ts +++ b/src/cli/plugins-list-command.ts @@ -76,11 +76,15 @@ export async function runPluginsListCommand( } = await loadHumanListModules(); if (list.length === 0) { - runtime.log( - theme.muted( - `No plugins found. Run ${formatCliCommand("openclaw plugins install ")} to add one, or ${formatCliCommand("openclaw plugins list --json")} to inspect raw discovery state.`, - ), - ); + const message = + opts.enabled && report.plugins.length > 0 + ? `${ + cfg.plugins?.enabled === false + ? "No enabled plugins found. Plugins are globally disabled." + : "No enabled plugins found." + } Run ${formatCliCommand("openclaw plugins list")} to inspect installed plugins.` + : `No plugins found. Run ${formatCliCommand("openclaw plugins install ")} to add one, or ${formatCliCommand("openclaw plugins list --json")} to inspect raw discovery state.`; + runtime.log(theme.muted(message)); return; } diff --git a/src/cli/run-main.exit.test.ts b/src/cli/run-main.exit.test.ts index d4b3d3c76e90..457b01f53f3d 100644 --- a/src/cli/run-main.exit.test.ts +++ b/src/cli/run-main.exit.test.ts @@ -83,7 +83,7 @@ const loadPluginCliDescriptorsMock = vi.hoisted(() => const resolveManifestCommandAliasOwnerMock = vi.hoisted(() => vi.fn()); const resolveManifestToolOwnerMock = vi.hoisted(() => vi.fn()); const resolveManifestCliCommandSurfaceOwnerMock = vi.hoisted(() => vi.fn()); -const restoreTerminalStateMock = vi.hoisted(() => vi.fn()); +const restoreRuntimeTerminalStateMock = vi.hoisted(() => vi.fn()); const hasEnvHttpProxyAgentConfiguredMock = vi.hoisted(() => vi.fn(() => false)); const ensureGlobalUndiciEnvProxyDispatcherMock = vi.hoisted(() => vi.fn()); const readConfigFileSnapshotMock = vi.hoisted(() => @@ -350,9 +350,13 @@ vi.mock("../plugins/manifest-command-aliases.runtime.js", () => ({ resolveManifestToolOwner: resolveManifestToolOwnerMock, })); -vi.mock("../../packages/terminal-core/src/restore.js", () => ({ - restoreTerminalState: restoreTerminalStateMock, -})); +vi.mock("../runtime.js", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + restoreRuntimeTerminalState: restoreRuntimeTerminalStateMock, + }; +}); vi.mock("../infra/net/proxy-env.js", () => ({ hasEnvHttpProxyAgentConfigured: hasEnvHttpProxyAgentConfiguredMock, @@ -4071,43 +4075,48 @@ describe("runCli exit behavior", () => { ]); }); - it("restores terminal state before uncaught CLI exits", async () => { - buildProgramMock.mockReturnValueOnce({ - commands: [{ name: () => "status" }], - parseAsync: vi.fn().mockResolvedValueOnce(undefined), - }); - - const processOnSpy = vi.spyOn(process, "on"); - const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); - const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { - throw new Error(`process.exit(${String(code)})`); - }) as typeof process.exit); - - await runCli(["node", "openclaw", "status"]); - - const handler = processOnSpy.mock.calls.find(([event]) => event === "uncaughtException")?.[1]; - if (typeof handler !== "function") { - throw new Error("uncaughtException handler was not registered"); - } - - try { - expect(() => handler(new Error("boom"))).toThrow("process.exit(1)"); - expect(consoleErrorSpy).toHaveBeenCalledWith( - "[openclaw] OpenClaw hit an unexpected runtime error.", - ); - expect(consoleErrorSpy).toHaveBeenCalledWith("[openclaw] Reason: boom"); - expect(restoreTerminalStateMock).toHaveBeenCalledWith("uncaught exception", { - resumeStdinIfPaused: false, + it.each([false, true])( + "restores terminal state before uncaught CLI exits (machine output: %s)", + async (machineOutput) => { + buildProgramMock.mockReturnValueOnce({ + commands: [{ name: () => "status" }], + parseAsync: vi.fn().mockResolvedValueOnce(undefined), }); - } finally { - if (typeof handler === "function") { - process.off("uncaughtException", handler); + + const processOnSpy = vi.spyOn(process, "on"); + const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const exitSpy = vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new Error(`process.exit(${String(code)})`); + }) as typeof process.exit); + + await runCli(["node", "openclaw", "status"]); + + const handler = processOnSpy.mock.calls.find(([event]) => event === "uncaughtException")?.[1]; + if (typeof handler !== "function") { + throw new Error("uncaughtException handler was not registered"); } - consoleErrorSpy.mockRestore(); - exitSpy.mockRestore(); - processOnSpy.mockRestore(); - } - }); + + try { + loggingState.forceConsoleToStderr = machineOutput; + expect(() => handler(new Error("boom"))).toThrow("process.exit(1)"); + expect(consoleErrorSpy).toHaveBeenCalledWith( + "[openclaw] OpenClaw hit an unexpected runtime error.", + ); + expect(consoleErrorSpy).toHaveBeenCalledWith("[openclaw] Reason: boom"); + expect(restoreRuntimeTerminalStateMock).toHaveBeenCalledWith("uncaught exception", { + resumeStdinIfPaused: false, + }); + } finally { + loggingState.forceConsoleToStderr = false; + if (typeof handler === "function") { + process.off("uncaughtException", handler); + } + consoleErrorSpy.mockRestore(); + exitSpy.mockRestore(); + processOnSpy.mockRestore(); + } + }, + ); it("does not exit for transient uncaught CLI exceptions", async () => { buildProgramMock.mockReturnValueOnce({ @@ -4136,7 +4145,7 @@ describe("runCli exit behavior", () => { expect(consoleWarnSpy.mock.calls).toEqual([ ["[openclaw] Non-fatal uncaught exception (continuing):", hostUnreachable.stack], ]); - expect(restoreTerminalStateMock).not.toHaveBeenCalled(); + expect(restoreRuntimeTerminalStateMock).not.toHaveBeenCalled(); expect(exitSpy).not.toHaveBeenCalled(); } finally { if (typeof handler === "function") { diff --git a/src/cli/run-main.ts b/src/cli/run-main.ts index 988bc577cf4b..434488be503a 100644 --- a/src/cli/run-main.ts +++ b/src/cli/run-main.ts @@ -1456,7 +1456,7 @@ async function runCliWithPreparedOutputMode( isBenignUncaughtExceptionError, isUncaughtExceptionHandled, }, - { restoreTerminalState }, + { restoreRuntimeTerminalState }, ] = await startupTrace.measure("core-imports", () => Promise.all([ import("./program.js"), @@ -1464,7 +1464,7 @@ async function runCliWithPreparedOutputMode( import("./failure-output.js"), import("../infra/fatal-error-hooks.js"), import("../infra/unhandled-rejections.js"), - import("../../packages/terminal-core/src/restore.js"), + import("../runtime.js"), ]), ); const program = await startupTrace.measure("build-program", () => buildProgram()); @@ -1494,7 +1494,7 @@ async function runCliWithPreparedOutputMode( for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) { console.error("[openclaw]", message); } - restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false }); + restoreRuntimeTerminalState("uncaught exception", { resumeStdinIfPaused: false }); process.exit(1); }); diff --git a/src/cli/skills-cli.format.ts b/src/cli/skills-cli.format.ts index b48479e9de19..b1fe75918b90 100644 --- a/src/cli/skills-cli.format.ts +++ b/src/cli/skills-cli.format.ts @@ -36,7 +36,8 @@ function appendClawHubHint(output: string, json?: boolean): string { if (json) { return output; } - return `${output}\n\nTip: use \`openclaw skills search\`, \`openclaw skills install\`, and \`openclaw skills update\` for ClawHub-backed skills.`; + const command = formatCliCommand("openclaw skills"); + return `${output}\n\nTip: use \`${command} search\`, \`${command} install\`, and \`${command} update\` for ClawHub-backed skills.`; } function formatSkillStatus(skill: SkillStatusEntry): string { diff --git a/src/cli/skills-cli.test.ts b/src/cli/skills-cli.test.ts index 4744477c5c45..a52f6b24c439 100644 --- a/src/cli/skills-cli.test.ts +++ b/src/cli/skills-cli.test.ts @@ -1,5 +1,5 @@ // Skills CLI tests cover skill listing, install, and command output behavior. -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { SkillStatusEntry, SkillStatusReport } from "../skills/discovery/status.js"; import { createEmptyInstallChecks } from "./requirements-test-fixtures.js"; import { formatSkillInfo, formatSkillsCheck, formatSkillsList } from "./skills-cli.format.js"; @@ -51,6 +51,66 @@ function createMockReport(skills: SkillStatusEntry[]): SkillStatusReport { } describe("skills-cli", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + describe("ClawHub command hints", () => { + it.each([ + { + name: "named profile", + profile: "work", + container: "", + prefix: "openclaw --profile work", + }, + { + name: "managed container", + profile: "", + container: "demo", + prefix: "openclaw --container demo", + }, + { + name: "default profile", + profile: "default", + container: "", + prefix: "openclaw", + }, + ])("preserves the $name on every human skill surface", ({ profile, container, prefix }) => { + vi.stubEnv("OPENCLAW_PROFILE", profile); + vi.stubEnv("OPENCLAW_CONTAINER_HINT", container); + const report = createMockReport([]); + const outputs = [ + formatSkillsList(report, {}), + formatSkillInfo(report, "missing-skill", {}), + formatSkillsCheck(report, {}), + ]; + + for (const output of outputs) { + for (const action of ["search", "install", "update"]) { + expect(output).toContain(`${prefix} skills ${action}`); + } + } + }); + + it("keeps profile and container guidance out of machine-readable skill output", () => { + vi.stubEnv("OPENCLAW_PROFILE", "work"); + vi.stubEnv("OPENCLAW_CONTAINER_HINT", "demo"); + const report = createMockReport([]); + const outputs = [ + formatSkillsList(report, { json: true }), + formatSkillInfo(report, "missing-skill", { json: true }), + formatSkillsCheck(report, { json: true }), + ]; + + for (const output of outputs) { + expect(() => JSON.parse(output)).not.toThrow(); + expect(output).not.toContain("Tip:"); + expect(output).not.toContain("openclaw --profile"); + expect(output).not.toContain("openclaw --container"); + } + }); + }); + describe("formatSkillsList", () => { it("formats empty skills list", () => { const report = createMockReport([]); diff --git a/src/cli/update-cli.test.ts b/src/cli/update-cli.test.ts index 1ca2851c9ff1..84fa03cf52b4 100644 --- a/src/cli/update-cli.test.ts +++ b/src/cli/update-cli.test.ts @@ -6140,7 +6140,7 @@ describe("update-cli", () => { "Commit, stash, or discard the local changes, then rerun `openclaw update`.", ); expect(serviceStop).not.toHaveBeenCalled(); - expect(defaultRuntime.exit).toHaveBeenCalledWith(0); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); }); it.each([ { diff --git a/src/cli/update-cli/update-command-post-update.test.ts b/src/cli/update-cli/update-command-post-update.test.ts index 27c6f701283d..f8eeb2faceae 100644 --- a/src/cli/update-cli/update-command-post-update.test.ts +++ b/src/cli/update-cli/update-command-post-update.test.ts @@ -44,6 +44,45 @@ async function finishFailedUpdate(result: UpdateRunResult): Promise { } as unknown as FinishUpdateParams); } +async function finishSkippedUpdate(reason: string): Promise { + await finishUpdate({ + result: { + status: "skipped", + mode: reason === "dirty" ? "git" : "unknown", + reason, + steps: [], + durationMs: 1, + }, + opts: {}, + showProgress: false, + controlPlaneUpdateSentinelMeta: undefined, + } as unknown as FinishUpdateParams); +} + +describe("skipped update exit status", () => { + beforeEach(() => { + vi.clearAllMocks(); + vi.spyOn(defaultRuntime, "exit").mockImplementation(() => undefined as never); + vi.spyOn(defaultRuntime, "error").mockImplementation(() => undefined); + vi.spyOn(defaultRuntime, "log").mockImplementation(() => undefined); + }); + + it("exits nonzero when local changes block a Git update", async () => { + await finishSkippedUpdate("dirty"); + + expect(defaultRuntime.error).toHaveBeenCalledWith( + expect.stringContaining("Update blocked: local files are edited"), + ); + expect(defaultRuntime.exit).toHaveBeenCalledWith(1); + }); + + it("keeps a non-Git install skip successful", async () => { + await finishSkippedUpdate("not-git-install"); + + expect(defaultRuntime.exit).toHaveBeenCalledWith(0); + }); +}); + describe("failed Git update recovery restart", () => { beforeEach(() => { vi.clearAllMocks(); diff --git a/src/cli/update-cli/update-command-post-update.ts b/src/cli/update-cli/update-command-post-update.ts index ac7d91854660..59477277c0a3 100644 --- a/src/cli/update-cli/update-command-post-update.ts +++ b/src/cli/update-cli/update-command-post-update.ts @@ -169,7 +169,7 @@ export async function finishUpdate(params: { ), ); } - defaultRuntime.exit(0); + defaultRuntime.exit(params.result.reason === "dirty" ? 1 : 0); return; } diff --git a/src/commands/doctor-main-session-recovery.test.ts b/src/commands/doctor-main-session-recovery.test.ts new file mode 100644 index 000000000000..d62db7758c77 --- /dev/null +++ b/src/commands/doctor-main-session-recovery.test.ts @@ -0,0 +1,96 @@ +import path from "node:path"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { useAutoCleanupTempDirTracker } from "../../test/helpers/temp-dir.js"; +import type { InternalSessionEntry } from "../config/sessions.js"; +import { loadSessionEntry, upsertSessionEntry } from "../config/sessions/session-accessor.js"; +import { closeOpenClawAgentDatabasesForTest } from "../state/openclaw-agent-db.js"; +import { noteMainSessionRecoveryIntegrity } from "./doctor-main-session-recovery.js"; + +const agentId = "main"; +const sessionKey = "agent:main:wedged-main"; +const reason = "restart recovery exhausted after 3 attempts"; +const tempDirs = useAutoCleanupTempDirTracker(afterEach); + +function countLabel(count: number, singular: string, plural = `${singular}s`): string { + return `${count} ${count === 1 ? singular : plural}`; +} + +describe("doctor main-session recovery integrity", () => { + let storePath = ""; + + beforeEach(() => { + storePath = path.join(tempDirs.make("openclaw-doctor-main-recovery-"), "sessions.json"); + }); + + afterEach(() => { + closeOpenClawAgentDatabasesForTest(); + }); + + async function writeTombstone(abortedLastRun: boolean): Promise { + await upsertSessionEntry({ agentId, sessionKey, storePath }, { + sessionId: "session-wedged-main", + updatedAt: abortedLastRun ? 0 : 1, + status: "failed", + abortedLastRun, + mainRestartRecovery: { + cycleId: "cycle-wedged-main", + revision: 4, + chargedAttempts: 3, + tombstone: { reason }, + }, + } as InternalSessionEntry); + } + + it("warns about a tombstone without offering stale repair", async () => { + await writeTombstone(false); + const warnings: string[] = []; + const changes: string[] = []; + const confirmRepair = vi.fn(async () => false); + + await noteMainSessionRecoveryIntegrity({ + agentId, + storePath, + warnings, + changes, + confirmRepair, + countLabel, + }); + + expect(warnings.join("\n")).toContain("automatic restart recovery tombstoned"); + expect(warnings.join("\n")).toContain(sessionKey); + expect(warnings.join("\n")).toContain(reason); + expect(confirmRepair).not.toHaveBeenCalled(); + expect(changes).toEqual([]); + expect(loadSessionEntry({ sessionKey, storePath })?.abortedLastRun).toBe(false); + }); + + it("clears a stale aborted flag while preserving the tombstone", async () => { + await writeTombstone(true); + const warnings: string[] = []; + const changes: string[] = []; + const confirmRepair = vi.fn(async () => true); + + await noteMainSessionRecoveryIntegrity({ + agentId, + storePath, + warnings, + changes, + confirmRepair, + countLabel, + }); + + expect(confirmRepair).toHaveBeenCalledWith({ + message: "Clear stale aborted recovery flags for 1 wedged main session?", + initialValue: true, + }); + const persisted = loadSessionEntry({ sessionKey, storePath }) as + | InternalSessionEntry + | undefined; + expect(persisted?.abortedLastRun).toBe(false); + expect(persisted?.updatedAt).toBeGreaterThan(0); + expect(persisted?.mainRestartRecovery?.tombstone?.reason).toBe(reason); + expect(changes).toEqual([ + "- Cleared aborted restart-recovery flags for 1 wedged main session.", + ]); + }); +}); diff --git a/src/commands/doctor-main-session-recovery.ts b/src/commands/doctor-main-session-recovery.ts index c81e55ad765b..524d8d907e88 100644 --- a/src/commands/doctor-main-session-recovery.ts +++ b/src/commands/doctor-main-session-recovery.ts @@ -1,6 +1,6 @@ import { uniqueStrings } from "@openclaw/normalization-core/string-normalization"; -import { inspectMainSessionRecoveryHealth } from "../agents/main-session-recovery-lifecycle.js"; import { transitionMainSessionRecovery } from "../agents/main-session-recovery-state.js"; +import type { InternalSessionEntry } from "../config/sessions.js"; import { applySessionEntryReplacements, listSessionEntries, @@ -20,8 +20,20 @@ export async function noteMainSessionRecoveryIntegrity( ): Promise { const entries = listSessionEntries({ agentId: params.agentId, storePath: params.storePath }); const wedged = entries.flatMap(({ entry, sessionKey }) => { - const health = inspectMainSessionRecoveryHealth(entry); - return health.status === "tombstoned" ? [{ key: sessionKey, health }] : []; + const tombstone = (entry as InternalSessionEntry).mainRestartRecovery?.tombstone; + return tombstone + ? [ + { + key: sessionKey, + health: { + reason: + tombstone.reason.trim() || + "main-session restart recovery is tombstoned for this session", + repair: entry.abortedLastRun === true ? "clear_stale_abort" : null, + }, + }, + ] + : []; }); if (wedged.length === 0) { return entries.length; diff --git a/src/commands/doctor/shared/pristine-startup-state.test.ts b/src/commands/doctor/shared/pristine-startup-state.test.ts index d9f668bda9a6..7513352f775e 100644 --- a/src/commands/doctor/shared/pristine-startup-state.test.ts +++ b/src/commands/doctor/shared/pristine-startup-state.test.ts @@ -148,6 +148,51 @@ describe("pristine startup state", () => { expect(canSkipPristineStartupStateMigrations(env)).toBe(true); }); + it("accepts canonical internal hook configuration", () => { + const env = createFixture({ + hooks: { + internal: { + enabled: true, + entries: { + "session-memory": { + enabled: true, + env: { OPENCLAW_HOOK_TEST: "enabled" }, + customOption: "value", + }, + }, + }, + }, + }); + + expect(planPristineStartupStateMigrations(env)).toEqual({ + skipAllStateMigrations: true, + skipCoreStateMigrations: true, + }); + }); + + it("retains migrations for legacy, external, and malformed hook configuration", () => { + const unsafeHooks = [ + { gmail: { account: "operator@example.com" } }, + { internal: { installs: { "session-memory": { source: "bundled" } } } }, + { internal: { handlers: [] } }, + { internal: { load: { extraDirs: ["/tmp/hooks"] } } }, + { internal: { enabled: "yes" } }, + { internal: { entries: [] } }, + { internal: { entries: { "session-memory": { enabled: "yes" } } } }, + { internal: { entries: { "session-memory": { env: { INVALID: true } } } } }, + ]; + + for (const hooks of unsafeHooks) { + expect( + planPristineStartupStateMigrations(createFixture({ hooks })), + JSON.stringify(hooks), + ).toEqual({ + skipAllStateMigrations: false, + skipCoreStateMigrations: false, + }); + } + }); + it("retains migrations for bundled plugins with doctor state surfaces", () => { const env = addBundledPlugin( createFixture({ plugins: { entries: { example: { enabled: true } } } }), diff --git a/src/commands/doctor/shared/pristine-startup-state.ts b/src/commands/doctor/shared/pristine-startup-state.ts index 74820aee496b..786088720a30 100644 --- a/src/commands/doctor/shared/pristine-startup-state.ts +++ b/src/commands/doctor/shared/pristine-startup-state.ts @@ -31,7 +31,6 @@ const STATEFUL_CONFIG_KEYS = new Set([ "cron", "discovery", "env", - "hooks", "marketplaces", "mcp", "media", @@ -48,6 +47,51 @@ const STATEFUL_CONFIG_KEYS = new Set([ "web", ]); +// Canonical internal entries have no legacy machine state to import. Keep every +// older or external hook shape on Doctor's full migration path. +function hasOnlyMigrationSafeInternalHooks(config: Record): boolean { + const hooks = config.hooks; + if (hooks === undefined) { + return true; + } + if (!isRecord(hooks) || Object.keys(hooks).some((key) => key !== "internal")) { + return false; + } + + const internal = hooks.internal; + if (internal === undefined) { + return true; + } + if ( + !isRecord(internal) || + Object.keys(internal).some((key) => !["enabled", "entries"].includes(key)) || + (internal.enabled !== undefined && typeof internal.enabled !== "boolean") + ) { + return false; + } + + if (internal.entries === undefined) { + return true; + } + if (!isRecord(internal.entries)) { + return false; + } + return Object.values(internal.entries).every((entry) => { + if (!isRecord(entry)) { + return false; + } + if (entry.enabled !== undefined && typeof entry.enabled !== "boolean") { + return false; + } + if (entry.env === undefined) { + return true; + } + return ( + isRecord(entry.env) && Object.values(entry.env).every((value) => typeof value === "string") + ); + }); +} + function containsObjectKey(value: unknown, targetKey: string): boolean { if (Array.isArray(value)) { return value.some((entry) => containsObjectKey(entry, targetKey)); @@ -142,6 +186,9 @@ function configIsPristineCoreStateSafe(config: Record): boolean if ([...STATEFUL_CONFIG_KEYS].some((key) => Object.hasOwn(config, key))) { return false; } + if (!hasOnlyMigrationSafeInternalHooks(config)) { + return false; + } if (containsObjectKey(config.agents, "memorySearch")) { return false; } diff --git a/src/commands/flows.test.ts b/src/commands/flows.test.ts index 83c41e15f5cc..ce4262dcb3b6 100644 --- a/src/commands/flows.test.ts +++ b/src/commands/flows.test.ts @@ -4,6 +4,7 @@ import type { RuntimeEnv } from "../runtime.js"; import { createRunningTaskRun as createRunningTaskRunOrNull } from "../tasks/task-executor.js"; import { createManagedTaskFlow as createManagedTaskFlowOrNull } from "../tasks/task-flow-registry.js"; import type { TaskFlowRecord } from "../tasks/task-flow-registry.types.js"; +import { markTaskLostById, markTaskTerminalById } from "../tasks/task-registry.js"; import type { TaskRecord } from "../tasks/task-registry.types.js"; import { resetTaskFlowRegistryForTests, @@ -282,6 +283,237 @@ describe("flows commands", () => { }); }); + it.each(["failed", "timed_out", "lost"] as const)( + "shows the persisted failure reason for linked %s tasks", + async (status) => { + await withTaskFlowCommandStateDir(async () => { + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: "tests/flows-command-failure-detail", + goal: "Inspect child task failures", + status: "running", + }); + const task = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: `agent:main:flow-child-${status}`, + runId: `run-flow-child-${status}`, + label: "Inspect linked child", + task: "Inspect linked child", + notifyPolicy: "silent", + startedAt: Date.now(), + progressSummary: "Outdated child progress", + }); + const error = `${status}: linked provider credentials need attention`; + const endedAt = Date.now(); + + if (status === "lost") { + markTaskLostById({ taskId: task.taskId, endedAt, error }); + } else { + markTaskTerminalById({ + taskId: task.taskId, + status, + endedAt, + error, + terminalSummary: "Generic child completion summary", + }); + } + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `)); + expect(linkedTaskLine).toContain("Inspect linked child"); + expect(linkedTaskLine).toContain(error); + expect(linkedTaskLine).not.toContain("Outdated child progress"); + expect(linkedTaskLine).not.toContain("Generic child completion summary"); + + const jsonRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRuntime); + expect(vi.mocked(jsonRuntime.writeJson).mock.calls[0]?.[0]).toMatchObject({ + tasks: [expect.objectContaining({ status, error })], + }); + }); + }, + ); + + it("includes running progress and terminal completion summaries for linked tasks", async () => { + await withTaskFlowCommandStateDir(async () => { + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: "tests/flows-command-task-progress", + goal: "Inspect child task updates", + status: "running", + }); + const running = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-running", + runId: "run-flow-child-running", + label: "Inspect running child", + task: "Inspect running child", + notifyPolicy: "silent", + startedAt: Date.now(), + progressSummary: "Downloading provider metadata", + }); + const completed = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-completed", + runId: "run-flow-child-completed", + label: "Inspect completed child", + task: "Inspect completed child", + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: completed.taskId, + status: "succeeded", + endedAt: Date.now(), + terminalSummary: "Provider metadata refreshed", + }); + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + expect(lines.find((line) => line.startsWith(`- ${running.taskId} `))).toContain( + "Downloading provider metadata", + ); + expect(lines.find((line) => line.startsWith(`- ${completed.taskId} `))).toContain( + "Provider metadata refreshed", + ); + }); + }); + + it("sanitizes linked task failure reasons before terminal display", async () => { + await withTaskFlowCommandStateDir(async () => { + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: "tests/flows-command-task-safety", + goal: "Inspect unsafe child error", + status: "running", + }); + const task = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: "agent:main:flow-child-safety", + runId: "run-flow-child-safety", + label: "Inspect child safely", + task: "Inspect child safely", + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: task.taskId, + status: "failed", + endedAt: Date.now(), + error: "Provider \u001b[31mrejected\nforged: yes", + }); + + const runtime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, runtime); + + const lines = vi.mocked(runtime.log).mock.calls.map(([line]) => String(line)); + const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `)); + expect(linkedTaskLine).toContain("Provider rejected forged: yes"); + expect(linkedTaskLine).not.toContain("\u001b"); + expect(linkedTaskLine).not.toContain("\n"); + }); + }); + + it("sanitizes persisted linked task identifiers while preserving raw flow JSON", async () => { + await withTaskFlowCommandStateDir(async () => { + const unsafe = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes"; + const flow = createManagedTaskFlow({ + ownerKey: "agent:main:main", + controllerId: `controller${unsafe}`, + goal: `goal${unsafe}`, + currentStep: `step${unsafe}`, + status: "running", + }); + const task = createRunningTaskRun({ + runtime: "subagent", + ownerKey: "agent:main:main", + scopeKind: "session", + parentFlowId: flow.flowId, + childSessionKey: `agent:main:child${unsafe}`, + runId: `run${unsafe}`, + label: `label${unsafe}`, + task: `prompt${unsafe}`, + notifyPolicy: "silent", + startedAt: Date.now(), + }); + markTaskTerminalById({ + taskId: task.taskId, + status: "failed", + endedAt: Date.now(), + error: `error${unsafe}`, + }); + + const humanRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId }, humanRuntime); + + const lines = vi.mocked(humanRuntime.log).mock.calls.map(([line]) => String(line)); + const linkedTaskLine = lines.find((line) => line.startsWith(`- ${task.taskId} `)); + expect(linkedTaskLine).toContain("label"); + expect(linkedTaskLine).toContain("error"); + for (const line of lines) { + expect(line).not.toContain("\u001b"); + expect(line).not.toContain("\u0007"); + expect(line).not.toContain("\n"); + } + + const jsonRuntime = createRuntime(); + await flowsShowCommand({ lookup: flow.flowId, json: true }, jsonRuntime); + expect(vi.mocked(jsonRuntime.writeJson).mock.calls[0]?.[0]).toMatchObject({ + goal: `goal${unsafe}`, + currentStep: `step${unsafe}`, + tasks: [ + expect.objectContaining({ + childSessionKey: `agent:main:child${unsafe}`, + runId: `run${unsafe}`, + label: `label${unsafe}`, + task: `prompt${unsafe}`, + error: `error${unsafe}`, + }), + ], + }); + }); + }); + + it("sanitizes untrusted TaskFlow filters and lookup errors", async () => { + await withTaskFlowCommandStateDir(async () => { + const unsafe = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes"; + const filterRuntime = createRuntime(); + await flowsListCommand({ status: `running${unsafe}` }, filterRuntime); + + const lookupRuntime = createRuntime(); + await flowsShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime); + + const lines = [ + ...vi.mocked(filterRuntime.log).mock.calls.map(([line]) => String(line)), + ...vi.mocked(lookupRuntime.error).mock.calls.map(([line]) => String(line)), + ]; + expect(lines.some((line) => line.includes("Status filter: running"))).toBe(true); + expect(lines.some((line) => line.includes("TaskFlow not found: missing"))).toBe(true); + for (const line of lines) { + expect(line).not.toContain("\u001b"); + expect(line).not.toContain("\u0007"); + expect(line).not.toContain("\n"); + } + }); + }); + it("shows TaskFlows with Date-invalid timestamps without crashing", async () => { await withTaskFlowCommandStateDir(async () => { const flow = createManagedTaskFlow({ diff --git a/src/commands/flows.ts b/src/commands/flows.ts index ad85553ee992..56e51c992f00 100644 --- a/src/commands/flows.ts +++ b/src/commands/flows.ts @@ -17,6 +17,7 @@ import { listTaskFlowRecords, resolveTaskFlowForLookupToken, } from "../tasks/task-flow-runtime-internal.js"; +import { formatTaskStatusDetail } from "../tasks/task-status.js"; const ID_PAD = 10; const STATUS_PAD = 10; @@ -25,7 +26,7 @@ const REV_PAD = 6; const CTRL_PAD = 20; function formatFlowLookupMiss(lookup: string): string { - return `TaskFlow not found: ${lookup}. Run ${formatCliCommand("openclaw tasks flow list")} to see recent flow ids.`; + return `TaskFlow not found: ${sanitizeTerminalText(lookup)}. Run ${formatCliCommand("openclaw tasks flow list")} to see recent flow ids.`; } function truncate(value: string, maxChars: number) { @@ -47,11 +48,7 @@ function safeFlowDisplayText(value: string | undefined, maxChars?: number): stri } function shortToken(value: string | undefined, maxChars = ID_PAD): string { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { - return "n/a"; - } - return truncate(trimmed, maxChars); + return safeFlowDisplayText(normalizeOptionalString(value), maxChars); } function formatFlowTimestamp(value: number | undefined | null): string { @@ -178,7 +175,7 @@ export async function flowsListCommand( runtime.log(info(`TaskFlows: ${flows.length}`)); runtime.log(info(`TaskFlow pressure: ${formatFlowListSummary(flows)}`)); if (statusFilter) { - runtime.log(info(`Status filter: ${statusFilter}`)); + runtime.log(info(`Status filter: ${sanitizeTerminalText(statusFilter)}`)); } if (flows.length === 0) { runtime.log( @@ -234,7 +231,7 @@ export async function flowsShowCommand( `tasks: ${taskSummary.total} total · ${taskSummary.active} active · ${taskSummary.failures} issues`, ]; for (const line of lines) { - runtime.log(line); + runtime.log(sanitizeTerminalText(line)); } if (tasks.length === 0) { runtime.log("Linked tasks: none"); @@ -243,7 +240,13 @@ export async function flowsShowCommand( runtime.log("Linked tasks:"); for (const task of tasks) { const safeLabel = safeFlowDisplayText(task.label ?? task.task); - runtime.log(`- ${task.taskId} ${task.status} ${task.runId ?? "n/a"} ${safeLabel}`); + const detail = formatTaskStatusDetail(task); + const safeDetail = detail ? ` · ${safeFlowDisplayText(detail)}` : ""; + runtime.log( + sanitizeTerminalText( + `- ${task.taskId} ${task.status} ${safeFlowDisplayText(task.runId)} ${safeLabel}${safeDetail}`, + ), + ); } } @@ -260,15 +263,21 @@ export async function flowsCancelCommand(opts: { lookup: string }, runtime: Runt flowId: flow.flowId, }); if (!result.found) { - runtime.error(result.reason ?? formatFlowLookupMiss(opts.lookup)); + runtime.error(sanitizeTerminalText(result.reason ?? formatFlowLookupMiss(opts.lookup))); runtime.exit(1); return; } if (!result.cancelled) { - runtime.error(result.reason ?? `Could not cancel TaskFlow: ${opts.lookup}`); + runtime.error( + sanitizeTerminalText(result.reason ?? `Could not cancel TaskFlow: ${opts.lookup}`), + ); runtime.exit(1); return; } const updated = getTaskFlowById(flow.flowId) ?? result.flow ?? flow; - runtime.log(`Cancelled ${updated.flowId} (${updated.syncMode}) with status ${updated.status}.`); + runtime.log( + sanitizeTerminalText( + `Cancelled ${updated.flowId} (${updated.syncMode}) with status ${updated.status}.`, + ), + ); } diff --git a/src/commands/status.scan.shared.test.ts b/src/commands/status.scan.shared.test.ts index 58ca7f554cf2..a2c8a50d17ba 100644 --- a/src/commands/status.scan.shared.test.ts +++ b/src/commands/status.scan.shared.test.ts @@ -1,8 +1,19 @@ // Status scan shared tests cover gateway probe snapshots, Tailscale URLs, and shared scan helpers. +import { once } from "node:events"; +import type { AddressInfo } from "node:net"; import path from "node:path"; import { DatabaseSync } from "node:sqlite"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { WebSocketServer } from "ws"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; +import { parseStatusRouteArgs } from "../cli/program/route-args.js"; +import { + buildMinimalGatewayHelloOkPayload, + closeMinimalGatewayServer, + parseMinimalGatewayRequestFrame, + sendMinimalGatewayConnectChallenge, + sendMinimalGatewayResponse, +} from "../gateway/minimal-gateway.test-helpers.js"; import { buildTailscaleHttpsUrl, resolveGatewayProbeSnapshot, @@ -325,39 +336,118 @@ describe("resolveGatewayProbeSnapshot", () => { expect(gatewayCall.timeoutMs).toBe(2000); }); - it("does not raise an explicit local status RPC fallback timeout", async () => { + it.each([1, 50, 999, 1000, 2000, 8000])( + "does not raise an explicit local status RPC fallback timeout (%i ms)", + async (timeoutMs) => { + mocks.resolveGatewayProbeTarget.mockReturnValue({ + mode: "local", + gatewayMode: "local", + remoteUrlMissing: false, + }); + mocks.probeGateway.mockResolvedValue({ + ok: false, + url: "ws://127.0.0.1:18789", + connectLatencyMs: null, + error: "timeout", + close: null, + auth: { + role: null, + scopes: [], + capability: "unknown", + }, + health: null, + status: null, + presence: null, + configSnapshot: null, + }); + mocks.callGateway.mockResolvedValue({ sessions: 1 }); + + await resolveGatewayProbeSnapshot({ + cfg: {}, + opts: { timeoutMs }, + }); + + const probeCall = readProbeCall(); + expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs"); + expect(probeCall.timeoutMs).toBe(timeoutMs); + expect(readGatewayCall().timeoutMs).toBe(Math.min(2000, timeoutMs)); + }, + ); + + it("enforces an explicit CLI timeout against a real local fallback status RPC", async () => { + const gateway = new WebSocketServer({ host: "127.0.0.1", port: 0 }); + await once(gateway, "listening"); + const address = gateway.address() as AddressInfo; + const url = `ws://127.0.0.1:${address.port}`; + const observedMethods: string[] = []; + gateway.on("connection", (socket) => { + sendMinimalGatewayConnectChallenge(socket); + socket.on("message", (data) => { + const frame = parseMinimalGatewayRequestFrame(data); + if (frame.type !== "req" || !frame.id || !frame.method) { + return; + } + const requestId = frame.id; + if (frame.method === "connect") { + sendMinimalGatewayResponse( + socket, + requestId, + buildMinimalGatewayHelloOkPayload({ + methods: ["system-presence", "status"], + auth: { role: "operator", scopes: ["operator.read"] }, + }), + ); + return; + } + observedMethods.push(frame.method); + if (frame.method === "status") { + const responseTimer = setTimeout(() => { + if (socket.readyState === socket.OPEN) { + sendMinimalGatewayResponse(socket, requestId, { sessions: 1 }); + } + }, 400); + responseTimer.unref(); + } + }); + }); + + mocks.buildGatewayConnectionDetailsWithResolvers.mockReturnValue({ + url, + urlSource: "local loopback", + message: `Gateway target: ${url}`, + }); mocks.resolveGatewayProbeTarget.mockReturnValue({ mode: "local", gatewayMode: "local", remoteUrlMissing: false, }); - mocks.probeGateway.mockResolvedValue({ - ok: false, - url: "ws://127.0.0.1:18789", - connectLatencyMs: null, - error: "timeout", - close: null, - auth: { - role: null, - scopes: [], - capability: "unknown", - }, - health: null, - status: null, - presence: null, - configSnapshot: null, + mocks.probeGateway.mockImplementation(async (...args: unknown[]) => { + const { probeGateway } = + await vi.importActual("../gateway/probe.js"); + return await probeGateway(...(args as Parameters)); }); - mocks.callGateway.mockResolvedValue({ sessions: 1 }); - - await resolveGatewayProbeSnapshot({ - cfg: {}, - opts: { timeoutMs: 1000 }, + mocks.callGateway.mockImplementation(async (...args: unknown[]) => { + const { callGateway } = + await vi.importActual("../gateway/call.js"); + return await callGateway(...(args as Parameters)); }); + const parsed = parseStatusRouteArgs(["node", "openclaw", "status", "--timeout", "250"]); + expect(parsed?.timeoutMs).toBe(250); - const probeCall = readProbeCall(); - expect(probeCall).not.toHaveProperty("preauthHandshakeTimeoutMs"); - expect(probeCall.timeoutMs).toBe(1000); - expect(readGatewayCall().timeoutMs).toBe(1000); + try { + const result = await resolveGatewayProbeSnapshot({ + cfg: { gateway: { auth: { mode: "none" } } }, + opts: { timeoutMs: parsed?.timeoutMs }, + }); + + expect(readProbeCall().timeoutMs).toBe(250); + expect(readGatewayCall().timeoutMs).toBe(250); + expect(observedMethods).toEqual(["system-presence", "status"]); + expect(result.gatewayProbe?.ok).toBe(false); + expect(result.gatewayProbe?.error).toContain("timeout"); + } finally { + await closeMinimalGatewayServer(gateway); + } }); it("lets callGateway reuse paired-device auth for local status RPC fallback", async () => { diff --git a/src/commands/status.scan.shared.ts b/src/commands/status.scan.shared.ts index fa70f4abd7b4..eb61f71bd4d0 100644 --- a/src/commands/status.scan.shared.ts +++ b/src/commands/status.scan.shared.ts @@ -208,7 +208,11 @@ async function applyLocalStatusRpcFallback(params: { if (!shouldTryLocalStatusRpcFallback(params)) { return params.gatewayProbe; } - const boundedFallbackTimeoutMs = Math.min(2000, Math.max(1000, params.timeoutMs)); + // Explicit probe budgets are operator-owned; only implicit fallback defaults get a floor. + const boundedFallbackTimeoutMs = Math.min( + 2000, + params.timeoutMsExplicit ? params.timeoutMs : Math.max(1000, params.timeoutMs), + ); // The fallback uses the gateway status RPC because it can succeed after probe handshake ambiguity. const status = await loadGatewayCallModule() .then(({ callGateway }) => diff --git a/src/commands/tasks.test.ts b/src/commands/tasks.test.ts index 084f87c5c513..32cb5a917269 100644 --- a/src/commands/tasks.test.ts +++ b/src/commands/tasks.test.ts @@ -12,6 +12,8 @@ import type { TaskFlowRecord } from "../tasks/task-flow-registry.types.js"; import { createTaskRecord as createTaskRecordOrNull, getTaskById, + markTaskLostById, + markTaskTerminalById, reloadTaskRegistryFromStore, } from "../tasks/task-registry.js"; import * as taskRegistryMaintenance from "../tasks/task-registry.maintenance.js"; @@ -79,6 +81,28 @@ function jsonRoundTrip(value: T): T { return JSON.parse(serialized) as T; } +const UNSAFE_TASK_TERMINAL_TEXT = "\u001b]52;c;Zm9yZ2Vk\u0007\nforged: yes"; + +function createInspectableTask(params: Partial[0]> = {}) { + return createTaskRecord({ + runtime: "cli", + ownerKey: "agent:main:main", + scopeKind: "session", + status: "running", + notifyPolicy: "silent", + task: "Inspect a background task", + ...params, + }); +} + +function expectSafeTaskOutput(runtime: RuntimeEnv, channel: "log" | "error" = "log") { + for (const [line] of vi.mocked(runtime[channel]).mock.calls) { + for (const control of ["\u001b", "\u0007", "\n", "\r"]) { + expect(String(line)).not.toContain(control); + } + } +} + const zeroTaskAuditCounts = { delivery_failed: 0, inconsistent_timestamps: 0, @@ -97,31 +121,28 @@ async function writeSessionEntries( } } +function resetTaskCommandRuntime() { + taskRegistryMaintenance.stopTaskRegistryMaintenance(); + taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); + resetConfigRuntimeState(); + resetDetachedTaskLifecycleRuntimeForTests(); + resetTaskRegistryDeliveryRuntimeForTests(); + resetTaskRegistryForTests({ persist: false }); + resetTaskFlowRegistryForTests({ persist: false }); + closeOpenClawAgentDatabasesForTest(); +} + async function withTaskCommandStateDir( run: (state: OpenClawTestState) => Promise, ): Promise { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-tasks-command-" }, async (state) => { - taskRegistryMaintenance.stopTaskRegistryMaintenance(); - taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); - resetConfigRuntimeState(); - resetDetachedTaskLifecycleRuntimeForTests(); - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - closeOpenClawAgentDatabasesForTest(); + resetTaskCommandRuntime(); try { await run(state); } finally { - taskRegistryMaintenance.stopTaskRegistryMaintenance(); - taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); - resetConfigRuntimeState(); - resetDetachedTaskLifecycleRuntimeForTests(); - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - closeOpenClawAgentDatabasesForTest(); + resetTaskCommandRuntime(); } }, ); @@ -134,14 +155,7 @@ describe("tasks commands", () => { afterEach(() => { vi.useRealTimers(); - taskRegistryMaintenance.stopTaskRegistryMaintenance(); - taskRegistryMaintenance.resetTaskRegistryMaintenanceRuntimeForTests(); - resetConfigRuntimeState(); - resetDetachedTaskLifecycleRuntimeForTests(); - resetTaskRegistryDeliveryRuntimeForTests(); - resetTaskRegistryForTests({ persist: false }); - resetTaskFlowRegistryForTests({ persist: false }); - closeOpenClawAgentDatabasesForTest(); + resetTaskCommandRuntime(); mocks.callGateway.mockReset(); }); @@ -390,6 +404,54 @@ describe("tasks commands", () => { }); }); + it.each(["gateway", "local"] as const)( + "sanitizes untrusted %s task cancellation output", + async (owner) => { + await withTaskCommandStateDir(async () => { + const unsafe = UNSAFE_TASK_TERMINAL_TEXT; + const gatewayOwned = owner === "gateway"; + const task = createInspectableTask({ + runtime: gatewayOwned ? "cron" : "cli", + ownerKey: gatewayOwned ? "" : "agent:main:main", + scopeKind: gatewayOwned ? "system" : "session", + runId: `run${unsafe}`, + }); + if (gatewayOwned) { + mocks.callGateway.mockResolvedValueOnce({ + found: true, + cancelled: true, + task: { + taskId: `${task.taskId}${unsafe}`, + runtime: `cron${unsafe}`, + runId: task.runId, + }, + }); + } + const runtime = createRuntime(); + await tasksCancelCommand({ lookup: task.taskId }, runtime); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining(`Cancelled ${task.taskId}`), + ); + expectSafeTaskOutput(runtime); + if (!gatewayOwned) { + expect(getTaskById(task.taskId)).toMatchObject({ + status: "cancelled", + runId: `run${unsafe}`, + }); + return; + } + mocks.callGateway.mockResolvedValueOnce({ + found: true, + cancelled: false, + reason: `gateway refused${unsafe}`, + }); + const failureRuntime = createRuntime(); + await tasksCancelCommand({ lookup: task.taskId }, failureRuntime); + expectSafeTaskOutput(failureRuntime, "error"); + }); + }, + ); + it("fails ACP task cancellation loudly when the live gateway is unavailable", async () => { await withTaskCommandStateDir(async () => { const task = createTaskRecord({ @@ -680,6 +742,110 @@ describe("tasks commands", () => { }); }); + it("sanitizes every persisted task surface while preserving raw task JSON", async () => { + await withTaskCommandStateDir(async () => { + const unsafe = UNSAFE_TASK_TERMINAL_TEXT; + const task = createInspectableTask({ + sourceId: `source${unsafe}`, + childSessionKey: `agent:main:child${unsafe}`, + parentTaskId: `parent${unsafe}`, + agentId: `worker${unsafe}`, + runId: `run${unsafe}`, + label: `label${unsafe}`, + task: `prompt${unsafe}`, + progressSummary: `progress${unsafe}`, + terminalSummary: `summary${unsafe}`, + }); + markTaskLostById({ taskId: task.taskId, endedAt: Date.now(), error: `error${unsafe}` }); + const showRuntime = createRuntime(); + const listRuntime = createRuntime(); + const auditRuntime = createRuntime(); + await tasksShowCommand({ lookup: task.taskId }, showRuntime); + await tasksListCommand({}, listRuntime); + await tasksAuditCommand({}, auditRuntime); + for (const runtime of [showRuntime, listRuntime, auditRuntime]) { + expectSafeTaskOutput(runtime); + } + const shown = vi + .mocked(showRuntime.log) + .mock.calls.map(([line]) => String(line)) + .join("|"); + for (const field of [ + "sourceId", + "childSessionKey", + "parentTaskId", + "agentId", + "runId", + "label", + "task", + "error", + "progressSummary", + "terminalSummary", + ]) { + expect(shown).toContain(`${field}:`); + } + expect(vi.mocked(listRuntime.log).mock.calls.flat().join("|")).toContain("error"); + expect(vi.mocked(auditRuntime.log).mock.calls.flat().join("|")).toContain("error"); + const jsonRuntime = createRuntime(); + await tasksShowCommand({ lookup: task.taskId, json: true }, jsonRuntime); + expect(readFirstJsonLog(jsonRuntime)).toEqual(jsonRoundTrip(getTaskById(task.taskId))); + expect(getTaskById(task.taskId)).toMatchObject({ + runId: `run${unsafe}`, + error: `error${unsafe}`, + }); + const filteredListRuntime = createRuntime(); + await tasksListCommand( + { runtime: `cron${unsafe}`, status: `running${unsafe}` }, + filteredListRuntime, + ); + const filteredAuditRuntime = createRuntime(); + await tasksAuditCommand( + { + severity: `warn${unsafe}` as TaskSystemAuditSeverity, + code: `lost${unsafe}` as TaskSystemAuditCode, + }, + filteredAuditRuntime, + ); + for (const runtime of [filteredListRuntime, filteredAuditRuntime]) { + expectSafeTaskOutput(runtime); + } + const lookupRuntime = createRuntime(); + await tasksShowCommand({ lookup: `missing${unsafe}` }, lookupRuntime); + expectSafeTaskOutput(lookupRuntime, "error"); + }); + }); + + it.each(["failed", "timed_out", "lost"] as const)( + "shows the persisted failure reason for %s tasks in list summaries", + async (status) => { + await withTaskCommandStateDir(async () => { + const task = createInspectableTask({ + runId: `task-list-${status}`, + label: "Original task title", + progressSummary: "Outdated running progress", + terminalSummary: "Generic terminal summary", + }); + const error = `${status}: upstream credentials need attention`; + const terminal = { taskId: task.taskId, endedAt: Date.now(), error }; + if (status === "lost") { + markTaskLostById(terminal); + } else { + markTaskTerminalById({ + ...terminal, + status, + terminalSummary: "Generic terminal summary", + }); + } + const runtime = createRuntime(); + await tasksListCommand({}, runtime); + const output = vi.mocked(runtime.log).mock.calls.flat().join("|"); + expect(output).toContain(error); + expect(output).not.toContain("Outdated running progress"); + expect(output).not.toContain("Generic terminal summary"); + }); + }, + ); + it("keeps task list summaries within their UTF-16 column limit", async () => { await withTaskCommandStateDir(async () => { createTaskRecord({ @@ -691,6 +857,8 @@ describe("tasks commands", () => { task: "Inspect task summary", terminalSummary: `${"y".repeat(78)}🚀xx`, }); + createInspectableTask({ progressSummary: "Fetching provider credentials" }); + createInspectableTask({ status: "succeeded", label: "Human-readable task title" }); const runtime = createRuntime(); await tasksListCommand({}, runtime); @@ -701,6 +869,8 @@ describe("tasks commands", () => { .join("\n"); expect(output).toContain(`${"y".repeat(78)}…`); expect(output).not.toContain("🚀"); + expect(output).toContain("Fetching provider credentials"); + expect(output).toContain("Human-readable task title"); }); }); diff --git a/src/commands/tasks.ts b/src/commands/tasks.ts index 97fe4bf0e565..2d3d10186b86 100644 --- a/src/commands/tasks.ts +++ b/src/commands/tasks.ts @@ -4,6 +4,7 @@ import { timestampMsToIsoString } from "@openclaw/normalization-core/number-coercion"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice"; +import { sanitizeTerminalText } from "../../packages/terminal-core/src/safe-text.js"; import { isRich, theme } from "../../packages/terminal-core/src/theme.js"; import { formatCliCommand } from "../cli/command-format.js"; import { formatLookupMiss } from "../cli/error-format.js"; @@ -42,6 +43,7 @@ import { } from "../tasks/task-registry.reconcile.js"; import { summarizeTaskRecords } from "../tasks/task-registry.summary.js"; import type { TaskNotifyPolicy, TaskRecord } from "../tasks/task-registry.types.js"; +import { formatTaskStatusDetail } from "../tasks/task-status.js"; import { buildTaskSystemAuditJsonPayload, buildTaskSystemAuditFindings, @@ -62,7 +64,7 @@ const info = theme.info; function formatTaskLookupMiss(lookup: string): string { return formatLookupMiss({ noun: "Task", - value: lookup, + value: sanitizeTerminalText(lookup), listCommand: "openclaw tasks list", valueLabel: "task id", }); @@ -208,11 +210,11 @@ function truncate(value: string, maxChars: number) { } function shortToken(value: string | undefined, maxChars = ID_PAD): string { - const trimmed = normalizeOptionalString(value); - if (!trimmed) { + const sanitized = sanitizeTerminalText(normalizeOptionalString(value) ?? "").trim(); + if (!sanitized) { return "n/a"; } - return truncate(trimmed, maxChars); + return truncate(sanitized, maxChars); } function formatTaskStatusCell(status: string, rich: boolean) { @@ -245,10 +247,9 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) { const lines = [rich ? theme.heading(header) : header]; for (const task of tasks) { const summary = truncate( - normalizeOptionalString(task.terminalSummary) || - normalizeOptionalString(task.progressSummary) || - normalizeOptionalString(task.label) || - task.task.trim(), + sanitizeTerminalText( + formatTaskStatusDetail(task) || normalizeOptionalString(task.label) || task.task.trim(), + ), 80, ); const line = [ @@ -257,7 +258,7 @@ function formatTaskRows(tasks: TaskRecord[], rich: boolean) { formatTaskStatusCell(task.status, rich), task.deliveryStatus.padEnd(DELIVERY_PAD), shortToken(task.runId, RUN_PAD).padEnd(RUN_PAD), - truncate(normalizeOptionalString(task.childSessionKey) || "n/a", 36).padEnd(36), + shortToken(task.childSessionKey, 36).padEnd(36), summary, ].join(" "); lines.push(line.trimEnd()); @@ -318,7 +319,7 @@ function formatAuditRows(findings: TaskSystemAuditFinding[], rich: boolean) { shortToken(finding.token).padEnd(ID_PAD), status, formatAgeMs(finding.ageMs).padEnd(8), - truncate(finding.detail, 88), + truncate(sanitizeTerminalText(finding.detail), 88), ] .join(" ") .trimEnd(), @@ -372,10 +373,10 @@ export async function tasksListCommand( runtime.log(info(`Background tasks: ${tasks.length}`)); runtime.log(info(`Task pressure: ${formatTaskListSummary(tasks)}`)); if (runtimeFilter) { - runtime.log(info(`Runtime filter: ${runtimeFilter}`)); + runtime.log(info(`Runtime filter: ${sanitizeTerminalText(runtimeFilter)}`)); } if (statusFilter) { - runtime.log(info(`Status filter: ${statusFilter}`)); + runtime.log(info(`Status filter: ${sanitizeTerminalText(statusFilter)}`)); } if (tasks.length === 0) { runtime.log( @@ -432,7 +433,7 @@ export async function tasksShowCommand( ...(task.terminalSummary ? [`terminalSummary: ${task.terminalSummary}`] : []), ]; for (const line of lines) { - runtime.log(line); + runtime.log(sanitizeTerminalText(line)); } } @@ -456,7 +457,9 @@ export async function tasksNotifyCommand( runtime.exit(1); return; } - runtime.log(`Updated ${updated.taskId} notify policy to ${updated.notifyPolicy}.`); + runtime.log( + sanitizeTerminalText(`Updated ${updated.taskId} notify policy to ${updated.notifyPolicy}.`), + ); } /** Cancels a detached task run by lookup token. */ @@ -470,18 +473,24 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt const gatewayResult = await tryCancelGatewayOwnedTaskViaGateway(task); if (gatewayResult) { if (!gatewayResult.found) { - runtime.error(gatewayResult.reason ?? formatTaskLookupMiss(opts.lookup)); + runtime.error( + sanitizeTerminalText(gatewayResult.reason ?? formatTaskLookupMiss(opts.lookup)), + ); runtime.exit(1); return; } if (!gatewayResult.cancelled) { - runtime.error(gatewayResult.reason ?? `Could not cancel task: ${opts.lookup}`); + runtime.error( + sanitizeTerminalText(gatewayResult.reason ?? `Could not cancel task: ${opts.lookup}`), + ); runtime.exit(1); return; } const updated = gatewayResult.task; runtime.log( - `Cancelled ${updated?.taskId ?? updated?.id ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + sanitizeTerminalText( + `Cancelled ${updated?.taskId ?? updated?.id ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + ), ); return; } @@ -490,18 +499,20 @@ export async function tasksCancelCommand(opts: { lookup: string }, runtime: Runt taskId: task.taskId, }); if (!result.found) { - runtime.error(result.reason ?? formatTaskLookupMiss(opts.lookup)); + runtime.error(sanitizeTerminalText(result.reason ?? formatTaskLookupMiss(opts.lookup))); runtime.exit(1); return; } if (!result.cancelled) { - runtime.error(result.reason ?? `Could not cancel task: ${opts.lookup}`); + runtime.error(sanitizeTerminalText(result.reason ?? `Could not cancel task: ${opts.lookup}`)); runtime.exit(1); return; } const updated = getTaskById(task.taskId); runtime.log( - `Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + sanitizeTerminalText( + `Cancelled ${updated?.taskId ?? task.taskId} (${updated?.runtime ?? task.runtime})${updated?.runId ? ` run ${updated.runId}` : ""}.`, + ), ); } @@ -549,10 +560,10 @@ export async function tasksAuditCommand( runtime.log(info(`Showing ${filteredFindings.length} matching findings.`)); } if (severityFilter) { - runtime.log(info(`Severity filter: ${severityFilter}`)); + runtime.log(info(`Severity filter: ${sanitizeTerminalText(severityFilter)}`)); } if (codeFilter) { - runtime.log(info(`Code filter: ${codeFilter}`)); + runtime.log(info(`Code filter: ${sanitizeTerminalText(codeFilter)}`)); } if (limit) { runtime.log(info(`Limit: ${limit}`)); diff --git a/src/config/sessions/session-accessor.entry.ts b/src/config/sessions/session-accessor.entry.ts index fd8e4b063445..dd463fb07805 100644 --- a/src/config/sessions/session-accessor.entry.ts +++ b/src/config/sessions/session-accessor.entry.ts @@ -12,6 +12,7 @@ import { clearPluginOwnedSessionState } from "./plugin-host-cleanup.js"; import { countSqliteSessionEntryRowsReadOnly as countSessionEntryRowsReadOnly, copySqliteSessionOwnedStateForCanonicalRepair as copySessionOwnedStateForCanonicalRepair, + hasSqliteSessionEntriesByStatusReadOnly as hasSessionEntriesByStatusReadOnly, listSqliteSessionGenerationIdsForCanonicalRepair as listSessionGenerationIdsForCanonicalRepair, listSqliteSessionChildEntriesReadOnly as listSessionChildEntriesReadOnly, listSqliteSessionEntries, @@ -61,6 +62,7 @@ export { clearPluginOwnedSessionState }; export { countSessionEntryRowsReadOnly, copySessionOwnedStateForCanonicalRepair, + hasSessionEntriesByStatusReadOnly, listSessionGenerationIdsForCanonicalRepair, listSessionChildEntriesReadOnly, listSessionEntriesReadOnly, diff --git a/src/config/sessions/session-accessor.readonly.test.ts b/src/config/sessions/session-accessor.readonly.test.ts index 214678a32e87..5a052c000998 100644 --- a/src/config/sessions/session-accessor.readonly.test.ts +++ b/src/config/sessions/session-accessor.readonly.test.ts @@ -4,6 +4,7 @@ import { cleanupTempDirs, makeTempDir } from "../../../test/helpers/temp-dir.js" import { closeOpenClawAgentDatabasesForTest, isOpenClawAgentDatabaseOpen, + openOpenClawAgentDatabase, resolveOpenClawAgentSqlitePath, } from "../../state/openclaw-agent-db.js"; import { @@ -11,6 +12,7 @@ import { openOpenClawStateDatabase, } from "../../state/openclaw-state-db.js"; import { + hasSessionEntriesByStatusReadOnly, listSessionEntries, listSessionEntriesReadOnly, resolveTranscriptSessionKeyBySessionId, @@ -68,6 +70,42 @@ describe("session accessor readonly listing", () => { expect(countRegisteredAgentDatabases(env)).toBe(0); }); + it("probes lifecycle status without creating or registering a missing database", () => { + const stateDir = makeTempDir(tempDirs, "openclaw-session-readonly-status-missing-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const agentId = "worker-1"; + const databasePath = resolveOpenClawAgentSqlitePath({ agentId, env }); + clearRegisteredAgentDatabases(env); + + expect(hasSessionEntriesByStatusReadOnly({ agentId, env }, ["running"])).toBe(false); + expect(fs.existsSync(databasePath)).toBe(false); + expect(countRegisteredAgentDatabases(env)).toBe(0); + }); + + it("distinguishes non-session agent state from a running session row", async () => { + const stateDir = makeTempDir(tempDirs, "openclaw-session-readonly-status-existing-"); + const env = { OPENCLAW_STATE_DIR: stateDir }; + const agentId = "worker-1"; + const databasePath = resolveOpenClawAgentSqlitePath({ agentId, env }); + openOpenClawAgentDatabase({ agentId, env, path: databasePath }); + closeOpenClawAgentDatabasesForTest(); + clearRegisteredAgentDatabases(env); + + expect(hasSessionEntriesByStatusReadOnly({ agentId, env }, ["running"])).toBe(false); + expect(countRegisteredAgentDatabases(env)).toBe(0); + + await upsertSessionEntry( + { agentId, env, sessionKey: "agent:worker-1:main" }, + { sessionId: "session-1", status: "running", updatedAt: 10 }, + ); + closeOpenClawAgentDatabasesForTest(); + clearRegisteredAgentDatabases(env); + + expect(hasSessionEntriesByStatusReadOnly({ agentId, env }, ["running"])).toBe(true); + expect(hasSessionEntriesByStatusReadOnly({ agentId, env }, ["done"])).toBe(false); + expect(countRegisteredAgentDatabases(env)).toBe(0); + }); + it("resolves a missing session identity without creating or registering a database", () => { const stateDir = makeTempDir(tempDirs, "openclaw-session-readonly-missing-identity-"); const env = { OPENCLAW_STATE_DIR: stateDir }; diff --git a/src/config/sessions/session-accessor.sqlite-entry.ts b/src/config/sessions/session-accessor.sqlite-entry.ts index d12f1f94b158..9653af3dc7b9 100644 --- a/src/config/sessions/session-accessor.sqlite-entry.ts +++ b/src/config/sessions/session-accessor.sqlite-entry.ts @@ -297,6 +297,35 @@ export function countSqliteSessionEntryRowsReadOnly(scope: SessionEntryListScope return result.found ? result.value : 0; } +/** + * Proves whether a durable store has a row in one of the requested lifecycle states. + * Unknown existing schemas stay eligible so the writable owner can surface or repair them. + */ +export function hasSqliteSessionEntriesByStatusReadOnly( + scope: Partial>, + statuses: readonly SessionEntryStatus[], +): boolean { + const selectedStatuses = [...new Set(statuses)]; + if (selectedStatuses.length === 0) { + return false; + } + const resolved = resolveSqliteScope({ ...scope, sessionKey: "" }); + const result = withOpenClawAgentDatabaseReadOnly((database) => { + const db = getSessionKysely(database.db); + return Boolean( + executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("session_nodes") + .select("session_key") + .where("status", "in", selectedStatuses) + .limit(1), + ), + ); + }, toDatabaseOptions(resolved)); + return result.found ? result.value : result.reason !== "database-missing"; +} + function listSqliteSessionEntriesFromDatabase( database: Pick, resolved: ResolvedSqliteScope, diff --git a/src/config/sessions/session-accessor.sqlite.ts b/src/config/sessions/session-accessor.sqlite.ts index 324ae6189c97..14f9038da6d2 100644 --- a/src/config/sessions/session-accessor.sqlite.ts +++ b/src/config/sessions/session-accessor.sqlite.ts @@ -1,6 +1,7 @@ // Stable SQLite accessor surface. Domain owners live in the focused modules below. export { countSqliteSessionEntryRowsReadOnly, + hasSqliteSessionEntriesByStatusReadOnly, listSqliteSessionEntries, listSqliteSessionChildEntriesReadOnly, listSqliteSessionEntriesReadOnly, diff --git a/src/config/sessions/session-accessor.ts b/src/config/sessions/session-accessor.ts index 2ae27420ac88..987c61b05848 100644 --- a/src/config/sessions/session-accessor.ts +++ b/src/config/sessions/session-accessor.ts @@ -121,6 +121,7 @@ export type { export { countSessionEntryRowsReadOnly, copySessionOwnedStateForCanonicalRepair, + hasSessionEntriesByStatusReadOnly, listSessionGenerationIdsForCanonicalRepair, clearPluginOwnedSessionState, listSessionChildEntriesReadOnly, diff --git a/src/context-engine/context-engine.test.ts b/src/context-engine/context-engine.test.ts index 5a1c201100b0..0d0a1627990a 100644 --- a/src/context-engine/context-engine.test.ts +++ b/src/context-engine/context-engine.test.ts @@ -1036,6 +1036,45 @@ describe("Invalid engine fallback", () => { ); }); + it("coalesces fallback initialization across concurrent lifecycle failures", async () => { + const defaultFactory = vi.fn(async () => new LegacyContextEngine()); + registerContextEngineForOwner("legacy", defaultFactory, "core", { + allowSameOwnerRefresh: true, + }); + const engineId = uniqueEngineId("concurrent-runtime-fail"); + const assemble = vi.fn(async () => { + await Promise.resolve(); + throw new Error("plugin context unavailable"); + }); + registerTestContextEngine(engineId, () => ({ + info: { id: engineId, name: "Concurrent Context Engine" }, + async ingest() { + return { ingested: true }; + }, + assemble, + async compact() { + return { ok: true, compacted: false }; + }, + })); + const engine = await resolveContextEngine(configWithSlot(engineId)); + const messages = [makeMockMessage("user", "first"), makeMockMessage("user", "second")]; + + const results = await Promise.all( + messages.map((message, index) => + engine.assemble({ sessionId: `session-${index}`, messages: [message] }), + ), + ); + + expect(results.map(({ messages: assembled }) => assembled)).toEqual( + messages.map((message) => [message]), + ); + expect(assemble).toHaveBeenCalledTimes(2); + expect(defaultFactory).toHaveBeenCalledTimes(1); + expect(listContextEngineQuarantines()).toEqual([ + expect.objectContaining({ engineId, operation: "assemble" }), + ]); + }); + it("exposes fallback metadata on the same engine after lifecycle quarantine", async () => { const engineId = uniqueEngineId("runtime-fail-metadata"); const assemble = vi.fn(async () => { diff --git a/src/context-engine/delegate.ts b/src/context-engine/delegate.ts index b805100b86e7..8ceba9b559aa 100644 --- a/src/context-engine/delegate.ts +++ b/src/context-engine/delegate.ts @@ -54,7 +54,8 @@ function buildCompactionResultSessionTarget(params: { const suppliedAgentId = targetAgentId ?? requestedAgentId; const suppliedSessionId = normalizeOptionalString(params.sessionId); const suppliedSessionKey = targetSessionKey ?? requestedSessionKey; - const callerAgentId = suppliedAgentId ?? parseAgentSessionKey(suppliedSessionKey)?.agentId; + const suppliedSessionKeyAgentId = parseAgentSessionKey(suppliedSessionKey)?.agentId; + const callerAgentId = suppliedAgentId ?? suppliedSessionKeyAgentId; if ( (callerAgentId && marker && marker.agentId !== callerAgentId) || (targetStorePath && marker && path.resolve(marker.storePath) !== path.resolve(targetStorePath)) @@ -64,12 +65,11 @@ function buildCompactionResultSessionTarget(params: { if (marker && suppliedSessionId && marker.sessionId !== suppliedSessionId) { throw new Error("Context-engine successor identity is inconsistent"); } - const candidateSessionKey = suppliedSessionKey; const candidateEntry = - marker && candidateSessionKey + marker && suppliedSessionKey ? loadSessionEntry({ agentId: marker.agentId, - sessionKey: candidateSessionKey, + sessionKey: suppliedSessionKey, storePath: marker.storePath, }) : undefined; @@ -86,17 +86,12 @@ function buildCompactionResultSessionTarget(params: { ) : undefined; const callerAuthorizedMarkerKey = Boolean( - candidateSessionKey && - suppliedSessionKey && - candidateSessionKey === suppliedSessionKey && - (!candidateEntry || candidateEntry.sessionId === callerSessionId), + suppliedSessionKey && (!candidateEntry || candidateEntry.sessionId === callerSessionId), ); const markerSessionKey = marker - ? callerAuthorizedMarkerKey - ? candidateSessionKey - : candidateEntry?.sessionId === marker.sessionId - ? candidateSessionKey - : (preferredMarkerSessionKey ?? (candidateEntry ? undefined : candidateSessionKey)) + ? callerAuthorizedMarkerKey || candidateEntry?.sessionId === marker.sessionId + ? suppliedSessionKey + : (preferredMarkerSessionKey ?? (candidateEntry ? undefined : suppliedSessionKey)) : undefined; if (sessionFile && !marker) { throw new Error("Legacy context-engine file successors are unsupported"); @@ -106,7 +101,7 @@ function buildCompactionResultSessionTarget(params: { } if ( marker && - candidateSessionKey && + suppliedSessionKey && ((candidateEntry && candidateEntry.sessionId !== marker.sessionId && !callerAuthorizedMarkerKey) || @@ -114,11 +109,7 @@ function buildCompactionResultSessionTarget(params: { ) { throw new Error("Legacy context-engine successor session key is inconsistent"); } - if ( - marker && - parseAgentSessionKey(candidateSessionKey)?.agentId && - parseAgentSessionKey(candidateSessionKey)?.agentId !== marker.agentId - ) { + if (marker && suppliedSessionKeyAgentId && suppliedSessionKeyAgentId !== marker.agentId) { throw new Error("Legacy context-engine successor identity is inconsistent"); } const sessionId = marker?.sessionId ?? suppliedSessionId ?? targetSessionId ?? callerSessionId; @@ -239,16 +230,7 @@ function renderMemorySystemPromptAddition( params: MemoryPromptSectionParams, prepared?: PreparedMemoryPromptSection, ): string | undefined { - const lines = buildMemoryPromptSection( - { - availableTools: params.availableTools, - citationsMode: params.citationsMode, - agentId: params.agentId, - agentSessionKey: params.agentSessionKey, - sandboxed: params.sandboxed, - }, - prepared, - ); + const lines = buildMemoryPromptSection(params, prepared); if (lines.length === 0) { return undefined; } @@ -277,12 +259,6 @@ export function buildMemorySystemPromptAddition( export async function prepareMemorySystemPromptAddition( params: MemoryPromptSectionParams, ): Promise { - const prepared = await prepareMemoryPromptSection({ - availableTools: params.availableTools, - citationsMode: params.citationsMode, - agentId: params.agentId, - agentSessionKey: params.agentSessionKey, - sandboxed: params.sandboxed, - }); + const prepared = await prepareMemoryPromptSection(params); return renderMemorySystemPromptAddition(params, prepared); } diff --git a/src/context-engine/legacy.ts b/src/context-engine/legacy.ts index db8406dc6957..acb02e2b85b4 100644 --- a/src/context-engine/legacy.ts +++ b/src/context-engine/legacy.ts @@ -1,17 +1,7 @@ // Legacy context engine wraps pre-plugin context behavior behind the pluggable interface. -import type { AgentMessage } from "../agents/runtime/index.js"; -import type { MemoryCitationsMode } from "../config/types.memory.js"; import { delegateCompactionToRuntime } from "./delegate.js"; import { CONTEXT_ENGINE_HOST_PARAMS } from "./registry.js"; -import type { - ContextEngine, - ContextEngineInfo, - AssembleResult, - CompactResult, - ContextEngineRuntimeContext, - ContextEngineSessionTarget, - IngestResult, -} from "./types.js"; +import type { AssembleResult, ContextEngine, ContextEngineInfo } from "./types.js"; /** * LegacyContextEngine wraps the existing compaction behavior behind the @@ -29,25 +19,12 @@ export class LegacyContextEngine implements ContextEngine { acceptedHostParams: [...CONTEXT_ENGINE_HOST_PARAMS], }; - async ingest(_params: { - sessionId: string; - sessionKey?: string; - message: AgentMessage; - isHeartbeat?: boolean; - }): Promise { + async ingest(_params: Parameters[0]) { // No-op: SessionManager handles message persistence in the legacy flow return { ingested: false }; } - async assemble(params: { - sessionId: string; - sessionKey?: string; - messages: AgentMessage[]; - tokenBudget?: number; - availableTools?: Set; - citationsMode?: MemoryCitationsMode; - model?: string; - }): Promise { + async assemble(params: Parameters[0]): Promise { // Pass-through: the existing sanitize -> validate -> limit -> repair pipeline // in attempt.ts handles context assembly for the legacy engine. // We just return the messages as-is with a rough token estimate. @@ -57,33 +34,12 @@ export class LegacyContextEngine implements ContextEngine { }; } - async afterTurn(_params: { - sessionId: string; - sessionKey?: string; - sessionFile: string; - messages: AgentMessage[]; - prePromptMessageCount: number; - autoCompactionSummary?: string; - isHeartbeat?: boolean; - tokenBudget?: number; - runtimeContext?: ContextEngineRuntimeContext; - }): Promise { + async afterTurn(_params: Parameters>[0]): Promise { // No-op: legacy flow persists context directly in SessionManager. } - async compact(params: { - sessionId: string; - sessionKey: string; - agentId?: string; - sessionTarget?: ContextEngineSessionTarget; - tokenBudget?: number; - force?: boolean; - currentTokenCount?: number; - compactionTarget?: "budget" | "threshold"; - customInstructions?: string; - runtimeContext?: ContextEngineRuntimeContext; - }): Promise { - return await delegateCompactionToRuntime(params); + compact(params: Parameters[0]) { + return delegateCompactionToRuntime(params); } async dispose(): Promise { diff --git a/src/context-engine/quarantine-health.ts b/src/context-engine/quarantine-health.ts index 5acd357f85d0..389956298fe4 100644 --- a/src/context-engine/quarantine-health.ts +++ b/src/context-engine/quarantine-health.ts @@ -14,12 +14,8 @@ type PersistedContextEngineRuntimeQuarantine = { failedAt: Date; }; -type PersistedContextEngineQuarantineRecord = RuntimeHealthRecordEnvelope & { - engineId: string; - owner?: string; - operation: string; - reason: string; -}; +type PersistedContextEngineQuarantineRecord = RuntimeHealthRecordEnvelope & + Omit; function isNonEmptyString(value: unknown): value is string { return typeof value === "string" && value.trim().length > 0; @@ -56,10 +52,6 @@ const quarantineStore = createRuntimeHealthStore) { - return JSON.stringify([record.engineId, record.processId]); -} - export function recordPersistedContextEngineQuarantine( quarantine: PersistedContextEngineRuntimeQuarantine, ): void { @@ -72,19 +64,19 @@ export function recordPersistedContextEngineQuarantine( }; // The in-memory registry only records the first quarantine per engine, so // this is called at most once per (engine, process) and overwrite is safe. - quarantineStore.register(recordKey(record), record); + quarantineStore.register(JSON.stringify([record.engineId, record.processId]), record); } export function listPersistedContextEngineQuarantines(): PersistedContextEngineRuntimeQuarantine[] { - return quarantineStore.list().map((record) => { + return quarantineStore.list().map(({ engineId, operation, reason, owner, failedAtMs }) => { const quarantine: PersistedContextEngineRuntimeQuarantine = { - engineId: record.engineId, - operation: record.operation, - reason: record.reason, - failedAt: new Date(record.failedAtMs), + engineId, + operation, + reason, + failedAt: new Date(failedAtMs), }; - if (record.owner) { - quarantine.owner = record.owner; + if (owner) { + quarantine.owner = owner; } return quarantine; }); diff --git a/src/context-engine/registry.ts b/src/context-engine/registry.ts index c61438e86e60..2f8df3398803 100644 --- a/src/context-engine/registry.ts +++ b/src/context-engine/registry.ts @@ -21,7 +21,6 @@ import { import type { BootstrapResult, ContextEngine, - ContextEngineInfo, ContextEngineMaintenanceResult, IngestBatchResult, IngestResult, @@ -50,76 +49,130 @@ const GUARDED_CONTEXT_ENGINE_METHODS = new Set( export const CONTEXT_ENGINE_HOST_PARAMS = new Set( "sessionKey prompt runtimeSettings sessionTarget runtimeContext".split(" "), ); -function wrapContextEngineWithHostParamProjection(engine: ContextEngine): ContextEngine { - const removeAfter = getPluginCompatRecord("context-engine-legacy-host-param-default").removeAfter; - const accepted = engine.info.acceptedHostParams; - const engineRecord = engine as unknown as Record; - const wrappedRecord: Record = {}; - Object.defineProperty(wrappedRecord, "info", { get: () => engine.info }); - for (const methodName of GUARDED_CONTEXT_ENGINE_METHODS) { - const method = engineRecord[methodName]; - if (typeof method !== "function") { - continue; - } - wrappedRecord[methodName] = (params: Record) => { - // Removal(2026-08-12): undeclared engines get full params. Contract: context-engine-legacy-host-param-default. - const useLegacyDefault = - removeAfter !== undefined && new Date().toISOString().slice(0, 10) <= removeAfter; - const currentAccepted = accepted ?? (useLegacyDefault ? [] : undefined); - if (!currentAccepted) { - return method.call(engine, params); - } - const projected = Object.fromEntries( - Object.entries(params).filter( - ([key]) => currentAccepted.includes(key) || !CONTEXT_ENGINE_HOST_PARAMS.has(key), - ), - ); - return method.call(engine, projected); - }; - } - if (engine.dispose) { - wrappedRecord.dispose = engine.dispose.bind(engine); - } - return Object.create(engine, Object.getOwnPropertyDescriptors(wrappedRecord)) as ContextEngine; -} - type ResolvedContextEngineMetadata = { owner: string; -}; - -type RuntimeQuarantineProxyState = { engineId: string; - getResolvedFallbackEngine: () => ContextEngine | undefined; }; -const RESOLVED_CONTEXT_ENGINE_METADATA = new WeakMap< - ContextEngine, - ResolvedContextEngineMetadata ->(); -const RUNTIME_QUARANTINE_PROXY_STATE = new WeakMap(); +const resolvedEngineMetadata = new WeakMap(); function wrapResolvedContextEngine( engine: ContextEngine, - metadata: { - owner: string; - engineId: string; + metadata: ResolvedContextEngineMetadata & { defaultEngineId?: string; factoryCtx?: ContextEngineFactoryContext; }, ): ContextEngine { - const projected = wrapContextEngineWithHostParamProjection(engine); - const wrapped = + const removeAfter = getPluginCompatRecord("context-engine-legacy-host-param-default").removeAfter; + const accepted = engine.info.acceptedHostParams; + const fallback = metadata.defaultEngineId && metadata.factoryCtx && metadata.engineId !== metadata.defaultEngineId - ? wrapContextEngineWithRuntimeQuarantine({ - engine: projected, - engineId: metadata.engineId, - owner: metadata.owner, - defaultEngineId: metadata.defaultEngineId, - factoryCtx: metadata.factoryCtx, - }) - : projected; - RESOLVED_CONTEXT_ENGINE_METADATA.set(wrapped, metadata); + ? { defaultEngineId: metadata.defaultEngineId, factoryCtx: metadata.factoryCtx } + : undefined; + let fallbackEnginePromise: Promise | undefined; + let resolvedFallbackEngine: ContextEngine | undefined; + const getFallbackEngine = fallback + ? () => + (fallbackEnginePromise ??= resolveDefaultContextEngine( + fallback.defaultEngineId, + fallback.factoryCtx, + ).then((resolved) => { + resolvedFallbackEngine = resolved; + return resolved; + })) + : undefined; + const projectParams = (params: Record) => { + // Removal(2026-08-12): undeclared engines get full params. Contract: context-engine-legacy-host-param-default. + const useLegacyDefault = + removeAfter !== undefined && new Date().toISOString().slice(0, 10) <= removeAfter; + const currentAccepted = accepted ?? (useLegacyDefault ? [] : undefined); + return currentAccepted + ? Object.fromEntries( + Object.entries(params).filter( + ([key]) => currentAccepted.includes(key) || !CONTEXT_ENGINE_HOST_PARAMS.has(key), + ), + ) + : params; + }; + + // A fresh target keeps Proxy invariants compatible with frozen engines and private getters. + const wrapped = new Proxy( + Object.create(engine, { info: { get: () => engine.info } }) as ContextEngine, + { + get(_target, property) { + if (property === "info") { + if (!fallback || !getContextEngineQuarantine(metadata.engineId)) { + return engine.info; + } + return ( + resolvedFallbackEngine?.info ?? { + id: fallback.defaultEngineId, + name: + fallback.defaultEngineId === "legacy" + ? "Legacy Context Engine" + : `${fallback.defaultEngineId} Context Engine`, + } + ); + } + + const method = Reflect.get(engine, property, engine); + if (typeof method !== "function") { + return method; + } + if (!GUARDED_CONTEXT_ENGINE_METHODS.has(property)) { + return method.bind(engine); + } + if (!fallback || !getFallbackEngine) { + return (params: Record) => method.call(engine, projectParams(params)); + } + + const methodName = property as GuardedContextEngineMethodName; + return async (methodParams: Record) => { + const abortSignal = contextEngineAbortSignal(methodParams); + if (abortSignal?.aborted) { + const reason = abortSignal.reason; + throw reason instanceof Error + ? reason + : createAbortError( + typeof reason === "string" && reason + ? reason + : "Context engine operation aborted.", + ); + } + const invokeFallback = () => + invokeFallbackContextEngineMethod({ getFallbackEngine, methodName, methodParams }); + if (getContextEngineQuarantine(metadata.engineId)) { + // Runtime failures downgrade future guarded calls for this process. + return await invokeFallback(); + } + + try { + return await method.call(engine, projectParams(methodParams)); + } catch (error) { + if (isContextEngineAbortRejection(error, abortSignal)) { + // Abort is caller intent, not engine instability; never quarantine for it. + throw error; + } + recordContextEngineQuarantine({ + engineId: metadata.engineId, + owner: metadata.owner, + operation: methodName, + error, + defaultEngineId: fallback.defaultEngineId, + }); + if (methodName === "compact" || methodName === "prepareSubagentSpawn") { + throw error; + } + return await invokeFallback().catch(() => { + throw error; + }); + } + }; + }, + }, + ); + resolvedEngineMetadata.set(wrapped, metadata); return wrapped; } @@ -163,10 +216,6 @@ function requireContextEngineOwner(owner: string): string { return normalizedOwner; } -function formatContextEngineError(error: unknown): string { - return error instanceof Error ? error.message : String(error); -} - function recordContextEngineQuarantine(params: { engineId: string; owner?: string; @@ -183,7 +232,7 @@ function recordContextEngineQuarantine(params: { const quarantine: ContextEngineRuntimeQuarantine = { engineId: params.engineId, operation: params.operation, - reason: formatContextEngineError(params.error), + reason: params.error instanceof Error ? params.error.message : String(params.error), failedAt: new Date(), ...(params.owner ? { owner: params.owner } : {}), }; @@ -211,23 +260,13 @@ export function listContextEngineQuarantines(): ContextEngineRuntimeQuarantine[] ({ failedAt, ...quarantine }) => ({ ...quarantine, failedAt: new Date(failedAt) }), ); const seenEngineIds = new Set(quarantines.map((entry) => entry.engineId)); - for (const entry of listPersistedContextEngineQuarantines()) { - if (seenEngineIds.has(entry.engineId)) { - continue; - } - quarantines.push(entry); - seenEngineIds.add(entry.engineId); - } - return quarantines; + return quarantines.concat( + listPersistedContextEngineQuarantines().filter(({ engineId }) => !seenEngineIds.has(engineId)), + ); } -function clearContextEngineRuntimeQuarantine(engineId?: string): void { - const quarantinedEngines = contextEngineRegistryState.quarantinedEngines; - if (engineId === undefined) { - quarantinedEngines.clear(); - } else { - quarantinedEngines.delete(engineId); - } +function clearContextEngineRuntimeQuarantine(engineId: string): void { + contextEngineRegistryState.quarantinedEngines.delete(engineId); clearPersistedContextEngineQuarantineForProcess(engineId, process.pid); } @@ -324,7 +363,10 @@ export function clearContextEnginesForOwner(owner: string): void { export function resolveContextEngineOwnerPluginId( engine: ContextEngine | undefined | null, ): string | undefined { - const owner = engine && resolveEffectiveContextEngineMetadata(engine)?.owner; + const metadata = engine ? resolvedEngineMetadata.get(engine) : undefined; + // Quarantined work belongs to its core-owned fallback, never the disabled plugin. + const owner = + metadata && !getContextEngineQuarantine(metadata.engineId) ? metadata.owner : undefined; if (!owner?.startsWith("plugin:")) { return undefined; } @@ -332,23 +374,6 @@ export function resolveContextEngineOwnerPluginId( return pluginId || undefined; } -function resolveEffectiveContextEngineMetadata( - engine: ContextEngine, -): ResolvedContextEngineMetadata | undefined { - const quarantineState = RUNTIME_QUARANTINE_PROXY_STATE.get(engine); - if (quarantineState && getContextEngineQuarantine(quarantineState.engineId)) { - // After quarantine, metadata follows the resolved fallback so plugin-scoped operations do not - // keep attributing work to a disabled engine. - const fallbackEngine = quarantineState.getResolvedFallbackEngine(); - return ( - (fallbackEngine ? RESOLVED_CONTEXT_ENGINE_METADATA.get(fallbackEngine) : undefined) ?? { - owner: CORE_CONTEXT_ENGINE_OWNER, - } - ); - } - return RESOLVED_CONTEXT_ENGINE_METADATA.get(engine); -} - function describeResolvedContextEngineContractError( engineId: string, engine: unknown, @@ -405,14 +430,10 @@ const CONTEXT_ENGINE_FALLBACK_RESULTS = { }; function contextEngineAbortSignal(methodParams: unknown): AbortSignal | undefined { - if (!methodParams || typeof methodParams !== "object") { - return undefined; - } - const signal = (methodParams as { abortSignal?: unknown }).abortSignal; - if (signal && typeof signal === "object" && "aborted" in signal) { - return signal as AbortSignal; - } - return undefined; + const signal = (methodParams as { abortSignal?: unknown } | null | undefined)?.abortSignal; + return signal && typeof signal === "object" && "aborted" in signal + ? (signal as AbortSignal) + : undefined; } function isContextEngineAbortRejection(error: unknown, signal: AbortSignal | undefined): boolean { @@ -450,93 +471,6 @@ async function invokeFallbackContextEngineMethod(params: { return fallbackResult ? { ...fallbackResult } : undefined; } -function wrapContextEngineWithRuntimeQuarantine(params: { - engine: ContextEngine; - engineId: string; - owner: string; - defaultEngineId: string; - factoryCtx: ContextEngineFactoryContext; -}): ContextEngine { - let fallbackEnginePromise: Promise | undefined; - let resolvedFallbackEngine: ContextEngine | undefined; - const getFallbackEngine = () => { - fallbackEnginePromise ??= resolveDefaultContextEngine( - params.defaultEngineId, - params.factoryCtx, - ).then((engine) => { - resolvedFallbackEngine = engine; - return engine; - }); - return fallbackEnginePromise; - }; - const fallbackInfo = (): ContextEngineInfo => - resolvedFallbackEngine?.info ?? { - id: params.defaultEngineId, - name: - params.defaultEngineId === "legacy" - ? "Legacy Context Engine" - : `${params.defaultEngineId} Context Engine`, - }; - const isQuarantined = () => Boolean(getContextEngineQuarantine(params.engineId)); - - const proxy = new Proxy(params.engine, { - get(target, property, receiver) { - if (property === "info" && isQuarantined()) { - return fallbackInfo(); - } - const value = Reflect.get(target, property, receiver); - if (typeof value !== "function" || !GUARDED_CONTEXT_ENGINE_METHODS.has(property)) { - return typeof value === "function" ? value.bind(target) : value; - } - - const methodName = property as GuardedContextEngineMethodName; - return async (methodParams: unknown) => { - const abortSignal = contextEngineAbortSignal(methodParams); - if (abortSignal?.aborted) { - const reason = abortSignal.reason; - throw reason instanceof Error - ? reason - : createAbortError( - typeof reason === "string" && reason ? reason : "Context engine operation aborted.", - ); - } - const invokeFallback = () => - invokeFallbackContextEngineMethod({ getFallbackEngine, methodName, methodParams }); - if (isQuarantined()) { - // Runtime failures downgrade future guarded calls for this process. - return await invokeFallback(); - } - - try { - return await (value as (methodParams: unknown) => unknown).call(target, methodParams); - } catch (error) { - if (isContextEngineAbortRejection(error, abortSignal)) { - // Abort is caller intent, not engine instability; never quarantine for it. - throw error; - } - recordContextEngineQuarantine({ - engineId: params.engineId, - owner: params.owner, - operation: methodName, - error, - defaultEngineId: params.defaultEngineId, - }); - if (methodName === "compact" || methodName === "prepareSubagentSpawn") { - throw error; - } - return await invokeFallback().catch(() => { - throw error; - }); - } - }; - }, - }); - RUNTIME_QUARANTINE_PROXY_STATE.set(proxy, { - engineId: params.engineId, - getResolvedFallbackEngine: () => resolvedFallbackEngine, - }); - return proxy; -} // --------------------------------------------------------------------------- // Resolution // --------------------------------------------------------------------------- diff --git a/src/context-engine/runtime-settings.ts b/src/context-engine/runtime-settings.ts index 715ab337c153..5c2ad151f4b6 100644 --- a/src/context-engine/runtime-settings.ts +++ b/src/context-engine/runtime-settings.ts @@ -1,3 +1,4 @@ +import { normalizeNullableString } from "@openclaw/normalization-core/string-coerce"; import type { ContextEngineHostSupport } from "./host-compat.js"; import type { ContextEngineRuntimeReasonCode, @@ -7,7 +8,6 @@ import type { } from "./types.js"; type OptionalString = string | null | undefined; -type OptionalReason = string | null | undefined; const RUNTIME_REASON_CODES = new Set([ "provider_timeout", @@ -17,20 +17,19 @@ const RUNTIME_REASON_CODES = new Set([ "runtime_unavailable", "unknown", ]); - -function normalizeNullableString(value: OptionalString): string | null { - if (typeof value !== "string") { - return null; - } - const trimmed = value.trim(); - return trimmed ? trimmed : null; -} +const RUNTIME_REASON_PATTERNS: Array<[ContextEngineRuntimeReasonCode, RegExp]> = [ + ["provider_timeout", /timeout/iu], + ["rate_limited", /rate|limit|429/iu], + ["context_overflow", /overflow|context|pressure/iu], + ["runtime_unavailable", /runtime/iu], + ["provider_unavailable", /provider|primary|unavailable/iu], +]; function normalizeNullableNumber(value: number | null | undefined): number | null { return typeof value === "number" && Number.isFinite(value) ? value : null; } -function normalizeReasonCode(value: OptionalReason): ContextEngineRuntimeReasonCode | null { +function normalizeReasonCode(value: OptionalString): ContextEngineRuntimeReasonCode | null { const normalized = normalizeNullableString(value); if (!normalized) { return null; @@ -39,23 +38,7 @@ function normalizeReasonCode(value: OptionalReason): ContextEngineRuntimeReasonC return normalized as ContextEngineRuntimeReasonCode; } - const lower = normalized.toLowerCase(); - if (lower.includes("timeout")) { - return "provider_timeout"; - } - if (lower.includes("rate") || lower.includes("limit") || lower.includes("429")) { - return "rate_limited"; - } - if (lower.includes("overflow") || lower.includes("context") || lower.includes("pressure")) { - return "context_overflow"; - } - if (lower.includes("runtime")) { - return "runtime_unavailable"; - } - if (lower.includes("provider") || lower.includes("primary") || lower.includes("unavailable")) { - return "provider_unavailable"; - } - return "unknown"; + return RUNTIME_REASON_PATTERNS.find(([, pattern]) => pattern.test(normalized))?.[0] ?? "unknown"; } export function buildContextEngineRuntimeSettings(params: { @@ -68,8 +51,8 @@ export function buildContextEngineRuntimeSettings(params: { modelFamily?: OptionalString; selectedContextEngineId?: OptionalString; contextEngineSelectionSource?: ContextEngineSelectionSource; - fallbackReason?: OptionalReason; - degradedReason?: OptionalReason; + fallbackReason?: OptionalString; + degradedReason?: OptionalString; promptTokenBudget?: number | null; maxOutputTokens?: number | null; contextEngineHost: ContextEngineHostSupport; diff --git a/src/daemon/systemd-unit.test.ts b/src/daemon/systemd-unit.test.ts index 7bdbd5822165..1ea4c8b37d42 100644 --- a/src/daemon/systemd-unit.test.ts +++ b/src/daemon/systemd-unit.test.ts @@ -1,6 +1,44 @@ // Systemd unit tests cover generated systemd unit files. import { describe, expect, it } from "vitest"; -import { buildSystemdUnit } from "./systemd-unit.js"; +import { + buildSystemdUnit, + parseSystemdEnvAssignments, + parseSystemdExecStart, + renderSystemdEnvAssignment, +} from "./systemd-unit.js"; + +// Values that need quoting, including the backslash and quote shapes the +// renderer has to escape for the module's own parsers to read them back. +const ROUND_TRIP_VALUES = [ + "plain", + "with space", + 'he said "hi"', + "back\\slash", + "C:\\\\srv\\\\bin", + 'mix \\ and " here', + "trailing\\", +]; + +describe("systemd unit value round-trips", () => { + it.each(ROUND_TRIP_VALUES)("round-trips %p through Environment=", (value) => { + const rendered = renderSystemdEnvAssignment("OPENCLAW_TOKEN", value); + expect(parseSystemdEnvAssignments(rendered)).toEqual([{ key: "OPENCLAW_TOKEN", value }]); + }); + + it.each(ROUND_TRIP_VALUES)("round-trips %p through ExecStart=", (value) => { + const unit = buildSystemdUnit({ + description: "OpenClaw Gateway", + programArguments: ["/usr/bin/openclaw", "gateway", value], + environment: {}, + }); + const execStart = unit.split("\n").find((line) => line.startsWith("ExecStart=")); + expect(parseSystemdExecStart(execStart?.slice("ExecStart=".length) ?? "")).toEqual([ + "/usr/bin/openclaw", + "gateway", + value, + ]); + }); +}); describe("buildSystemdUnit", () => { it("quotes arguments with whitespace", () => { diff --git a/src/daemon/systemd-unit.ts b/src/daemon/systemd-unit.ts index c1a61fd4480f..3353651a4cb2 100644 --- a/src/daemon/systemd-unit.ts +++ b/src/daemon/systemd-unit.ts @@ -16,9 +16,12 @@ function systemdEscapeArg(value: string): string { if (!/[\s"\\]/.test(value)) { return value; } - // systemd ExecStart/Environment parsing honors backslash escapes inside - // quotes; match that contract for round-trip parser tests. - return `"${value.replace(/\\\\/g, "\\\\\\\\").replace(/"/g, '\\\\"')}"`; + // systemd ExecStart/Environment parsing consumes one backslash before the next + // character, so every backslash and quote must be escaped for the value to + // survive the round-trip byte-for-byte. Escaping only backslash pairs left a + // lone backslash unescaped, and the reader then swallowed the byte after it. + const escaped = value.replaceAll("\\", "\\\\").replaceAll('"', '\\"'); + return `"${escaped}"`; } function renderEnvLines(env: Record | undefined): string[] { @@ -110,38 +113,17 @@ function parseSystemdEnvAssignment(raw: string): { key: string; value: string } return null; } - const unquoted = (() => { - const quote = trimmed[0]; - if (!((quote === '"' || quote === "'") && trimmed.endsWith(quote))) { - return trimmed; - } - let out = ""; - let escapeNext = false; - // systemd quote parsing consumes one backslash before the next character. - for (const ch of trimmed.slice(1, -1)) { - if (escapeNext) { - out += ch; - escapeNext = false; - continue; - } - if (ch === "\\\\") { - escapeNext = true; - continue; - } - out += ch; - } - return out; - })(); - - const eq = unquoted.indexOf("="); + // The shared splitter already removes quotes and consumes escapes before an + // assignment reaches this helper. + const eq = trimmed.indexOf("="); if (eq <= 0) { return null; } - const key = unquoted.slice(0, eq).trim(); + const key = trimmed.slice(0, eq).trim(); if (!key) { return null; } - const value = unquoted.slice(eq + 1); + const value = trimmed.slice(eq + 1); return { key, value }; } diff --git a/src/flows/doctor-lint-flow.test.ts b/src/flows/doctor-lint-flow.test.ts index 2c0c5db6ed75..1215118f4ae2 100644 --- a/src/flows/doctor-lint-flow.test.ts +++ b/src/flows/doctor-lint-flow.test.ts @@ -85,6 +85,81 @@ describe("runDoctorLintChecks", () => { }, ); + it.each(["array", "set"] as const)( + "runs surviving selected checks when selectors only partially overlap (%s)", + async (selectorShape) => { + const skippedId = "plugin/example/skipped"; + const selectedId = "plugin/example/selected"; + const detections: string[] = []; + const ids = [skippedId, selectedId]; + const result = await runDoctorLintChecks(ctx, { + checks: ids.map((id) => + check(id, async () => { + detections.push(id); + return []; + }), + ), + onlyIds: selectorShape === "set" ? new Set(ids) : ids, + skipIds: selectorShape === "set" ? new Set([skippedId]) : [skippedId], + }); + + expect(result).toEqual({ findings: [], checksRun: 1, checksSkipped: 1 }); + expect(detections).toEqual([selectedId]); + expect(exitCodeFromFindings(result.findings)).toBe(0); + }, + ); + + it("retains every overlap diagnostic when exclusion removes all selected checks", async () => { + const ids = ["plugin/example/first", "plugin/example/second"]; + const result = await runDoctorLintChecks(ctx, { + checks: ids.map((id) => check(id, async () => [])), + onlyIds: ids, + skipIds: ids, + }); + + expect(result.checksRun).toBe(0); + expect(result.checksSkipped).toBe(2); + expect(result.findings).toEqual( + ids.map((id) => ({ + checkId: "core/doctor/lint-selection", + severity: "error", + message: `Health check ${id} cannot be selected by --only and excluded by --skip.`, + path: id, + })), + ); + expect(exitCodeFromFindings(result.findings)).toBe(1); + }); + + it("keeps unknown-only diagnostics without treating partial overlap as an error", async () => { + const skippedId = "plugin/example/skipped"; + const selectedId = "plugin/example/selected"; + const unknownId = "plugin/future/not-loaded"; + const detections: string[] = []; + const result = await runDoctorLintChecks(ctx, { + checks: [skippedId, selectedId].map((id) => + check(id, async () => { + detections.push(id); + return []; + }), + ), + onlyIds: [skippedId, unknownId, selectedId], + skipIds: [skippedId, "plugin/future/ignored"], + }); + + expect(result.checksRun).toBe(1); + expect(result.checksSkipped).toBe(1); + expect(detections).toEqual([selectedId]); + expect(result.findings).toEqual([ + { + checkId: "core/doctor/lint-selection", + severity: "error", + message: `Unknown health check id selected by --only: ${unknownId}.`, + path: unknownId, + }, + ]); + expect(exitCodeFromFindings(result.findings)).toBe(1); + }); + it("keeps non-conflicting selection and exclusion filters independent", async () => { let selectedDetections = 0; let skippedDetections = 0; diff --git a/src/flows/doctor-lint-flow.ts b/src/flows/doctor-lint-flow.ts index 8161e948f747..d366bb6f89a9 100644 --- a/src/flows/doctor-lint-flow.ts +++ b/src/flows/doctor-lint-flow.ts @@ -53,7 +53,7 @@ export async function runDoctorLintChecks( let message: string; if (!allIds.has(id)) { message = `Unknown health check id selected by --only: ${id}.`; - } else if (skip.has(id)) { + } else if (selected.length === 0 && skip.has(id)) { message = `Health check ${id} cannot be selected by --only and excluded by --skip.`; } else { continue; diff --git a/src/gateway/chat-attachment-policy.test.ts b/src/gateway/chat-attachment-policy.test.ts new file mode 100644 index 000000000000..35f3ba32cc20 --- /dev/null +++ b/src/gateway/chat-attachment-policy.test.ts @@ -0,0 +1,69 @@ +// Attachment policy tests guard the numbers advertised on `hello-ok` against the +// ceilings the parser actually enforces. +import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; +import { describe, expect, it } from "vitest"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + DEFAULT_CHAT_ATTACHMENT_MAX_BYTES, + resolveChatAttachmentMaxBytes, + resolveChatAttachmentPolicy, +} from "./chat-attachment-policy.js"; + +const MB = 1024 * 1024; + +const cfgWithMediaMaxMb = (value: unknown): OpenClawConfig => + ({ agents: { defaults: { mediaMaxMb: value } } }) as unknown as OpenClawConfig; + +describe("resolveChatAttachmentMaxBytes", () => { + it("honours a configured agents.defaults.mediaMaxMb", () => { + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(10))).toBe(10 * MB); + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(50))).toBe(50 * MB); + }); + + it("falls back to the default ceiling when unset", () => { + expect(resolveChatAttachmentMaxBytes({} as OpenClawConfig)).toBe( + DEFAULT_CHAT_ATTACHMENT_MAX_BYTES, + ); + expect(resolveChatAttachmentMaxBytes({ agents: {} } as unknown as OpenClawConfig)).toBe( + DEFAULT_CHAT_ATTACHMENT_MAX_BYTES, + ); + }); + + it("rejects non-positive, non-finite, or non-number values", () => { + for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY, "50", null, undefined]) { + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(bad))).toBe( + DEFAULT_CHAT_ATTACHMENT_MAX_BYTES, + ); + } + }); + + it("never floors a legal sub-byte mediaMaxMb to zero", () => { + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(0.0000001))).toBe(1); + }); + + it("keeps an enormous mediaMaxMb representable instead of overflowing", () => { + expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(1e308))).toBe(Number.MAX_SAFE_INTEGER); + }); +}); + +describe("resolveChatAttachmentPolicy", () => { + it("advertises the configured ceiling with the image hydration cap applied", () => { + expect(resolveChatAttachmentPolicy(cfgWithMediaMaxMb(20))).toEqual({ + maxBytes: 20 * MB, + maxImageBytes: MAX_IMAGE_BYTES, + }); + }); + + it("clamps maxImageBytes to the configured ceiling when it is the smaller limit", () => { + expect(resolveChatAttachmentPolicy(cfgWithMediaMaxMb(1))).toEqual({ + maxBytes: MB, + maxImageBytes: MB, + }); + }); + + it("keeps both ceilings positive so the hello-ok schema stays satisfiable", () => { + const policy = resolveChatAttachmentPolicy(cfgWithMediaMaxMb(0.0000001)); + expect(policy.maxBytes).toBeGreaterThanOrEqual(1); + expect(policy.maxImageBytes).toBeGreaterThanOrEqual(1); + }); +}); diff --git a/src/gateway/chat-attachment-policy.ts b/src/gateway/chat-attachment-policy.ts new file mode 100644 index 000000000000..0332f41f2701 --- /dev/null +++ b/src/gateway/chat-attachment-policy.ts @@ -0,0 +1,42 @@ +// Connection-level chat attachment ceilings shared by the parser and the +// `hello-ok` handshake. Kept out of chat-attachments.ts so the handshake path +// does not pull the media probe/store graph in just to read two numbers. +import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; +import type { OpenClawConfig } from "../config/types.openclaw.js"; + +const DEFAULT_CHAT_ATTACHMENT_MAX_MB = 20; + +/** Default decoded-size ceiling when `agents.defaults.mediaMaxMb` is unset or invalid. */ +export const DEFAULT_CHAT_ATTACHMENT_MAX_BYTES = DEFAULT_CHAT_ATTACHMENT_MAX_MB * 1024 * 1024; + +/** Resolve the maximum decoded attachment size accepted for chat inputs. */ +export function resolveChatAttachmentMaxBytes(cfg: OpenClawConfig): number { + const configured = cfg.agents?.defaults?.mediaMaxMb; + const mb = + typeof configured === "number" && Number.isFinite(configured) && configured > 0 + ? configured + : DEFAULT_CHAT_ATTACHMENT_MAX_MB; + // mediaMaxMb only has to be positive, so a sub-byte value would floor to 0 and + // a huge one overflows to Infinity, which serializes as null on the handshake + // frame and fails its integer schema. Both ends have to stay representable. + return Math.min(Number.MAX_SAFE_INTEGER, Math.max(1, Math.floor(mb * 1024 * 1024))); +} + +/** Unconditional decoded-size ceilings advertised on `hello-ok.policy.attachments`. */ +type ChatAttachmentPolicy = { + maxBytes: number; + maxImageBytes: number; +}; + +/** + * Resolve the decoded-size ceilings every chat attachment faces regardless of + * entrypoint or model. Images are checked against the configured ceiling first + * and the agent-hydration cap second, so their effective limit is the smaller of + * the two. MIME acceptance and per-message counts are deliberately absent: they + * depend on the entrypoint, the resolved model, and payload sniffing, so they + * cannot be stated once per connection. + */ +export function resolveChatAttachmentPolicy(cfg: OpenClawConfig): ChatAttachmentPolicy { + const maxBytes = resolveChatAttachmentMaxBytes(cfg); + return { maxBytes, maxImageBytes: Math.min(maxBytes, MAX_IMAGE_BYTES) }; +} diff --git a/src/gateway/chat-attachments.test.ts b/src/gateway/chat-attachments.test.ts index 441ffd0a1af8..cbe8260b9295 100644 --- a/src/gateway/chat-attachments.test.ts +++ b/src/gateway/chat-attachments.test.ts @@ -33,11 +33,14 @@ vi.mock("../media/media-probe.js", () => ({ import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; import type { OpenClawConfig } from "../config/types.openclaw.js"; +import { + resolveChatAttachmentMaxBytes, + resolveChatAttachmentPolicy, +} from "./chat-attachment-policy.js"; import { type ChatAttachment, parseMessageWithAttachments, persistInboundImagesForTranscript, - resolveChatAttachmentMaxBytes, stripImageMediaMarkers, UnsupportedAttachmentError, } from "./chat-attachments.js"; @@ -67,13 +70,17 @@ function pdfAttachment(overrides: Partial = {}): ChatAttachment }; } -function oversizedPngBase64(): string { +function pngBase64OfBytes(bytes: number): string { const pngHeader = PNG_1x1.slice(0, 64); - let base64Length = Math.ceil(((MAX_IMAGE_BYTES + 1) * 4) / 3); + let base64Length = Math.ceil((bytes * 4) / 3); base64Length += (4 - (base64Length % 4)) % 4; return `${pngHeader}${"A".repeat(base64Length - pngHeader.length)}`; } +function oversizedPngBase64(): string { + return pngBase64OfBytes(MAX_IMAGE_BYTES + 1); +} + async function parseWithWarnings( message: string, attachments: ChatAttachment[], @@ -623,30 +630,47 @@ describe("parseMessageWithAttachments validation errors", () => { }); }); -describe("resolveChatAttachmentMaxBytes", () => { +describe("advertised attachment policy matches enforcement", () => { const MB = 1024 * 1024; - const DEFAULT_BYTES = 20 * MB; - const cfgWithMediaMaxMb = (value: unknown): OpenClawConfig => + const cfgWithMediaMaxMb = (value: number): OpenClawConfig => ({ agents: { defaults: { mediaMaxMb: value } } }) as unknown as OpenClawConfig; - it("honours a configured agents.defaults.mediaMaxMb", () => { - expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(10))).toBe(10 * MB); - expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(50))).toBe(50 * MB); - }); - - it("falls back to DEFAULT_CHAT_ATTACHMENT_MAX_MB when unset", () => { - expect(resolveChatAttachmentMaxBytes({} as OpenClawConfig)).toBe(DEFAULT_BYTES); - expect(resolveChatAttachmentMaxBytes({ agents: {} } as unknown as OpenClawConfig)).toBe( - DEFAULT_BYTES, + async function parseImageWithPolicy(cfg: OpenClawConfig, imageBytes: number) { + const policy = resolveChatAttachmentPolicy(cfg); + const parse = parseMessageWithAttachments( + "x", + [pngAttachment({ fileName: "big.png", content: pngBase64OfBytes(imageBytes) })], + { maxBytes: policy.maxBytes, log: { warn: () => {} } }, ); + return { policy, parse }; + } + + it("rejects images above the advertised maxImageBytes when the config ceiling is the smaller limit", async () => { + const { policy, parse } = await parseImageWithPolicy(cfgWithMediaMaxMb(1), 3 * MB); + expect(policy.maxImageBytes).toBe(MB); + await expect(parse).rejects.toThrow(/exceeds size limit/i); }); - it("rejects non-positive, non-finite, or non-number values", () => { - for (const bad of [0, -5, Number.NaN, Number.POSITIVE_INFINITY, "50", null, undefined]) { - expect(resolveChatAttachmentMaxBytes(cfgWithMediaMaxMb(bad))).toBe(DEFAULT_BYTES); + it("accepts images under the advertised maxImageBytes", async () => { + const { policy, parse } = await parseImageWithPolicy(cfgWithMediaMaxMb(20), 3 * MB); + expect(policy.maxImageBytes).toBe(MAX_IMAGE_BYTES); + const parsed = await parse; + try { + expect(parsed.offloadedRefs).toHaveLength(1); + } finally { + await cleanupOffloadedRefs(parsed.offloadedRefs); } }); + + it("rejects images above the advertised maxImageBytes when the hydration cap is the smaller limit", async () => { + const { policy, parse } = await parseImageWithPolicy( + cfgWithMediaMaxMb(20), + MAX_IMAGE_BYTES + 3, + ); + expect(policy.maxImageBytes).toBe(MAX_IMAGE_BYTES); + await expect(parse).rejects.toThrow(/image exceeds size limit/i); + }); }); describe("attachment validation", () => { diff --git a/src/gateway/chat-attachments.ts b/src/gateway/chat-attachments.ts index c8ec2520772a..116494ddabd0 100644 --- a/src/gateway/chat-attachments.ts +++ b/src/gateway/chat-attachments.ts @@ -5,7 +5,6 @@ import { MAX_IMAGE_BYTES, type MediaKind } from "@openclaw/media-core/constants" import { extensionForMime, kindFromMime, mimeTypeFromFilePath } from "@openclaw/media-core/mime"; import { expectDefined } from "@openclaw/normalization-core"; import { normalizeOptionalLowercaseString } from "@openclaw/normalization-core/string-coerce"; -import type { OpenClawConfig } from "../config/types.openclaw.js"; import { formatErrorMessage, formatUncaughtError } from "../infra/errors.js"; import type { SubsystemLogger } from "../logging/subsystem.js"; import type { MediaFact } from "../media/media-facts.js"; @@ -13,6 +12,7 @@ import { probeMediaFilesWithinBudget } from "../media/media-probe.js"; import type { PromptImageOrderEntry } from "../media/prompt-image-order.js"; import { sniffMimeFromBase64 } from "../media/sniff-mime-from-base64.js"; import { deleteMediaBuffer, saveMediaBuffer, type SavedMedia } from "../media/store.js"; +import { DEFAULT_CHAT_ATTACHMENT_MAX_BYTES } from "./chat-attachment-policy.js"; import { formatForLog } from "./ws-log.js"; export type ChatAttachment = { @@ -78,8 +78,6 @@ const MAX_CHAT_ATTACHMENT_MEDIA_PROBES = 8; const CHAT_ATTACHMENT_MEDIA_PROBE_CONCURRENCY = 2; const CHAT_ATTACHMENT_MEDIA_PROBE_BUDGET_MS = 3000; -const DEFAULT_CHAT_ATTACHMENT_MAX_MB = 20; - async function enrichOffloadedMediaMetadata(refs: OffloadedRef[]): Promise { const candidates = refs.flatMap((ref) => { const kind = kindFromMime(ref.mimeType); @@ -174,16 +172,6 @@ export async function persistInboundImagesForTranscript(params: { return ordered; } -/** Resolve the maximum decoded attachment size accepted for chat image inputs. */ -export function resolveChatAttachmentMaxBytes(cfg: OpenClawConfig): number { - const configured = cfg.agents?.defaults?.mediaMaxMb; - const mb = - typeof configured === "number" && Number.isFinite(configured) && configured > 0 - ? configured - : DEFAULT_CHAT_ATTACHMENT_MAX_MB; - return Math.floor(mb * 1024 * 1024); -} - type UnsupportedAttachmentReason = | "empty-payload" | "text-only-image" @@ -374,7 +362,7 @@ export async function parseMessageWithAttachments( acceptNonImage?: boolean; }, ): Promise { - const maxBytes = opts?.maxBytes ?? DEFAULT_CHAT_ATTACHMENT_MAX_MB * 1024 * 1024; + const maxBytes = opts?.maxBytes ?? DEFAULT_CHAT_ATTACHMENT_MAX_BYTES; const log = opts?.log; const shouldForceImageOffload = opts?.supportsImages === false; const supportsInlineImages = opts?.supportsInlineImages !== false; diff --git a/src/gateway/open-responses.schema.ts b/src/gateway/open-responses.schema.ts index 05bde4ece540..4a48e78b0145 100644 --- a/src/gateway/open-responses.schema.ts +++ b/src/gateway/open-responses.schema.ts @@ -279,7 +279,14 @@ export type OutputItem = z.infer; const UsageSchema = z.object({ input_tokens: z.number().int().nonnegative(), + input_tokens_details: z.object({ + cached_tokens: z.number().int().nonnegative(), + cache_write_tokens: z.number().int().nonnegative(), + }), output_tokens: z.number().int().nonnegative(), + output_tokens_details: z.object({ + reasoning_tokens: z.number().int().nonnegative(), + }), total_tokens: z.number().int().nonnegative(), }); diff --git a/src/gateway/openai-agent-run-usage.ts b/src/gateway/openai-agent-run-usage.ts new file mode 100644 index 000000000000..3ac384fa13e9 --- /dev/null +++ b/src/gateway/openai-agent-run-usage.ts @@ -0,0 +1,27 @@ +/** Shared agent-run usage selection for OpenAI-compatible Gateway endpoints. */ +import { + hasNonzeroUsage, + normalizeUsage, + type NormalizedUsage, + type UsageLike, +} from "../agents/usage.js"; + +type AgentRunUsageMeta = { + usage?: UsageLike; + lastCallUsage?: UsageLike; +}; + +/** Prefer a nonzero aggregate snapshot, then the latest model-call snapshot. */ +export function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { + const agentMeta = (result as { meta?: { agentMeta?: AgentRunUsageMeta } } | null)?.meta + ?.agentMeta; + const aggregate = normalizeUsage(agentMeta?.usage); + if (hasNonzeroUsage(aggregate)) { + return aggregate; + } + const lastCall = normalizeUsage(agentMeta?.lastCallUsage); + if (hasNonzeroUsage(lastCall)) { + return lastCall; + } + return aggregate ?? lastCall; +} diff --git a/src/gateway/openai-http.ts b/src/gateway/openai-http.ts index 47e9c046acf5..b42d3213965e 100644 --- a/src/gateway/openai-http.ts +++ b/src/gateway/openai-http.ts @@ -13,13 +13,7 @@ import { isClientToolNameConflictError } from "../agents/agent-tool-definition-a import type { AgentStreamParams, ClientToolDefinition } from "../agents/command/shared-types.js"; import type { ImageContent } from "../agents/command/types.js"; import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js"; -import { - hasNonzeroUsage, - normalizeUsage, - toOpenAiChatCompletionsUsage, - type NormalizedUsage, - type OpenAiChatCompletionsUsage, -} from "../agents/usage.js"; +import { toOpenAiChatCompletionsUsage, type OpenAiChatCompletionsUsage } from "../agents/usage.js"; import { createDefaultDeps } from "../cli/deps.js"; import { agentCommandFromIngress } from "../commands/agent.js"; import type { GatewayHttpChatCompletionsConfig } from "../config/types.gateway.js"; @@ -67,6 +61,7 @@ import { resolveOpenAiCompatibleHttpSenderIsOwner, } from "./http-utils.js"; import { normalizeInputHostnameAllowlist } from "./input-allowlist.js"; +import { resolveAgentRunUsage } from "./openai-agent-run-usage.js"; import { resolveOpenAiCompatError, validateOpenAiSamplingParams } from "./openai-compat-errors.js"; import { isToolChoiceConstraintSatisfied, @@ -766,42 +761,12 @@ function resolveAgentResponseCommentary(result: unknown): string { .join("\n\n"); } -type AgentUsageMeta = { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; -}; - type PendingToolCall = { id?: unknown; name?: unknown; arguments?: unknown; }; -function resolveAgentRunUsage(result: unknown): NormalizedUsage | undefined { - const agentMeta = ( - result as { - meta?: { - agentMeta?: { - usage?: AgentUsageMeta; - lastCallUsage?: AgentUsageMeta; - }; - }; - } | null - )?.meta?.agentMeta; - const primary = normalizeUsage(agentMeta?.usage); - if (hasNonzeroUsage(primary)) { - return primary; - } - const fallback = normalizeUsage(agentMeta?.lastCallUsage); - if (hasNonzeroUsage(fallback)) { - return fallback; - } - return primary ?? fallback; -} - function resolveStopReasonAndPendingToolCalls(meta: unknown): { stopReason: string | undefined; pendingToolCalls: Array<{ id: string; name: string; arguments: string }> | undefined; diff --git a/src/gateway/openresponses-http.test.ts b/src/gateway/openresponses-http.test.ts index 95a98e03cd93..9d7aad072a13 100644 --- a/src/gateway/openresponses-http.test.ts +++ b/src/gateway/openresponses-http.test.ts @@ -1135,7 +1135,15 @@ describe("OpenResponses HTTP API (e2e)", () => { mockAgentOnce([{ text: "ok" }], { agentMeta: { - usage: { input: 3, output: 5, cacheRead: 1, cacheWrite: 1 }, + usage: { + input: 3, + output: 5, + cacheRead: 1, + cacheWrite: 2, + reasoningTokens: 4, + total: 7, + }, + lastCallUsage: { input: 100, output: 100, total: 200 }, }, }); const resUsage = await postResponses(port, { @@ -1145,7 +1153,13 @@ describe("OpenResponses HTTP API (e2e)", () => { }); expect(resUsage.status).toBe(200); const usageJson = (await resUsage.json()) as Record; - expect(usageJson.usage).toEqual({ input_tokens: 3, output_tokens: 5, total_tokens: 10 }); + expect(usageJson.usage).toEqual({ + input_tokens: 6, + input_tokens_details: { cached_tokens: 1, cache_write_tokens: 2 }, + output_tokens: 5, + output_tokens_details: { reasoning_tokens: 4 }, + total_tokens: 11, + }); await ensureResponseConsumed(resUsage); mockAgentOnce([{ text: "hello" }]); @@ -1158,6 +1172,13 @@ describe("OpenResponses HTTP API (e2e)", () => { const shapeJson = (await resShape.json()) as Record; expect(shapeJson.object).toBe("response"); expect(shapeJson.status).toBe("completed"); + expect(shapeJson.usage).toEqual({ + input_tokens: 0, + input_tokens_details: { cached_tokens: 0, cache_write_tokens: 0 }, + output_tokens: 0, + output_tokens_details: { reasoning_tokens: 0 }, + total_tokens: 0, + }); expect(Array.isArray(shapeJson.output)).toBe(true); const output = shapeJson.output as Array>; @@ -1281,6 +1302,51 @@ describe("OpenResponses HTTP API (e2e)", () => { } }); + it.each([ + { name: "missing aggregate", usage: undefined }, + { name: "zero aggregate", usage: { input: 0, output: 0, total: 0 } }, + ])("uses last-call usage in the terminal SSE response for $name", async ({ usage }) => { + agentCommand.mockClear(); + agentCommand.mockResolvedValueOnce({ + payloads: [{ text: "hello" }], + meta: { + agentMeta: { + ...(usage ? { usage } : {}), + lastCallUsage: { + input: 4, + output: 3, + cacheRead: 2, + cacheWrite: 1, + reasoningTokens: 2, + total: 9, + }, + }, + }, + } as never); + + const client = new OpenAI({ + apiKey: "test", + baseURL: `http://127.0.0.1:${enabledPort}/v1`, + defaultHeaders: { "x-openclaw-scopes": "operator.write" }, + maxRetries: 0, + }); + const response = await client.responses + .stream({ + model: "openclaw", + input: "hi", + }) + .finalResponse(); + + expect(response.status).toBe("completed"); + expect(response.usage).toEqual({ + input_tokens: 7, + input_tokens_details: { cached_tokens: 2, cache_write_tokens: 1 }, + output_tokens: 3, + output_tokens_details: { reasoning_tokens: 2 }, + total_tokens: 10, + }); + }); + it("flushes same-turn assistant microtasks before completing an official SDK stream", async () => { agentCommand.mockClear(); agentCommand.mockImplementationOnce(((opts: unknown) => { diff --git a/src/gateway/openresponses-http.ts b/src/gateway/openresponses-http.ts index 702aa0cb4c26..ef7782184cea 100644 --- a/src/gateway/openresponses-http.ts +++ b/src/gateway/openresponses-http.ts @@ -12,6 +12,7 @@ import { resolveIntegerOption } from "@openclaw/normalization-core/number-coerci import { isClientToolNameConflictError } from "../agents/agent-tool-definition-adapter.js"; import type { ImageContent } from "../agents/command/types.js"; import type { ClientToolDefinition } from "../agents/embedded-agent-runner/run/params.js"; +import { toOpenAiResponsesUsage } from "../agents/usage.js"; import { createDefaultDeps } from "../cli/deps.js"; import type { CliDeps } from "../cli/deps.types.js"; import { agentCommandFromIngress } from "../commands/agent.js"; @@ -70,6 +71,7 @@ import { type StreamingEvent, type Usage, } from "./open-responses.schema.js"; +import { resolveAgentRunUsage } from "./openai-agent-run-usage.js"; import { resolveOpenAiCompatError } from "./openai-compat-errors.js"; import { isToolChoiceConstraintSatisfied, @@ -342,43 +344,11 @@ function applyToolChoice(params: { export { buildAgentPrompt } from "./openresponses-prompt.js"; function createEmptyUsage(): Usage { - return { input_tokens: 0, output_tokens: 0, total_tokens: 0 }; -} - -function toUsage( - value: - | { - input?: number; - output?: number; - cacheRead?: number; - cacheWrite?: number; - total?: number; - } - | undefined, -): Usage { - if (!value) { - return createEmptyUsage(); - } - const input = value.input ?? 0; - const output = value.output ?? 0; - const cacheRead = value.cacheRead ?? 0; - const cacheWrite = value.cacheWrite ?? 0; - const total = value.total ?? input + output + cacheRead + cacheWrite; - return { - input_tokens: Math.max(0, input), - output_tokens: Math.max(0, output), - total_tokens: Math.max(0, total), - }; + return toOpenAiResponsesUsage(undefined); } function extractUsageFromResult(result: unknown): Usage { - const meta = (result as { meta?: { agentMeta?: { usage?: unknown } } } | null)?.meta; - const usage = meta && typeof meta === "object" ? meta.agentMeta?.usage : undefined; - return toUsage( - usage as - | { input?: number; output?: number; cacheRead?: number; cacheWrite?: number; total?: number } - | undefined, - ); + return toOpenAiResponsesUsage(resolveAgentRunUsage(result)); } type PendingToolCall = { id: string; name: string; arguments: string }; diff --git a/src/gateway/server-methods/agent-content-phase.ts b/src/gateway/server-methods/agent-content-phase.ts index 709648470c44..cdc2e635efd2 100644 --- a/src/gateway/server-methods/agent-content-phase.ts +++ b/src/gateway/server-methods/agent-content-phase.ts @@ -29,11 +29,11 @@ import { isInternalNonDeliveryChannel, normalizeMessageChannel, } from "../../utils/message-channel.js"; +import { resolveChatAttachmentMaxBytes } from "../chat-attachment-policy.js"; import { MediaOffloadError, logAttachmentFailure, parseMessageWithAttachments, - resolveChatAttachmentMaxBytes, type ChatAttachment, } from "../chat-attachments.js"; import { diff --git a/src/gateway/server-methods/agent-run-execution-phase.ts b/src/gateway/server-methods/agent-run-execution-phase.ts index 21c5f21e29c4..e0a1a715bffc 100644 --- a/src/gateway/server-methods/agent-run-execution-phase.ts +++ b/src/gateway/server-methods/agent-run-execution-phase.ts @@ -12,11 +12,8 @@ import { type ExecApprovalContinuationPromptRange, } from "../../agents/bash-tools.exec-approval-output.js"; import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js"; +import { repairMainSessionRecoveryMutation } from "../../agents/main-session-recovery-lifecycle.js"; import { scheduleMainSessionRecoveryPendingTarget } from "../../agents/main-session-recovery-owner-release.js"; -import { - restoreAdmittedRecoveryWithRetries, - scheduleAdmittedRecoveryRestore, -} from "../../agents/main-session-recovery-restore.js"; import { releaseMainSessionRecoveryOwner, type MainSessionRecoveryPendingTarget, @@ -510,17 +507,16 @@ export function startAgentRunExecution(params: { claimId: execApprovalFollowupHandoffClaimId, }); try { - if (prepared.restoreAdmittedRestartRecoveryInterrupted) { - try { - pendingRecovery ??= await restoreAdmittedRecoveryWithRetries( - prepared.restoreAdmittedRestartRecoveryInterrupted, - ); - } catch (err) { - params.context.logGateway.warn( - `failed to restore undispatched restart recovery: ${formatForLog(err)}`, - ); - scheduleAdmittedRecoveryRestore(prepared.restoreAdmittedRestartRecoveryInterrupted); - } + const restoreAdmittedRecovery = prepared.restoreAdmittedRestartRecoveryInterrupted; + if (restoreAdmittedRecovery) { + pendingRecovery ??= await repairMainSessionRecoveryMutation({ + mutation: restoreAdmittedRecovery, + onDeferredSuccess: scheduleMainSessionRecoveryPendingTarget, + onError: (err) => + params.context.logGateway.warn( + `failed to restore undispatched restart recovery: ${formatForLog(err)}`, + ), + }); } } finally { try { diff --git a/src/gateway/server-methods/agent-session-persist.ts b/src/gateway/server-methods/agent-session-persist.ts index 08221cada6d5..223c32677d2a 100644 --- a/src/gateway/server-methods/agent-session-persist.ts +++ b/src/gateway/server-methods/agent-session-persist.ts @@ -1,11 +1,9 @@ import { randomUUID } from "node:crypto"; import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce"; import { ErrorCodes, errorShape } from "../../../packages/gateway-protocol/src/index.js"; -import { - isMainSessionRecoveryExhausted, - transitionMainSessionRecovery, -} from "../../agents/main-session-recovery-state.js"; +import { transitionMainSessionRecovery } from "../../agents/main-session-recovery-state.js"; import type { MainSessionRecoveryOwnerLease } from "../../agents/main-session-recovery-store.js"; +import { MAX_RECOVERY_RETRIES } from "../../agents/main-session-restart-recovery-shared.js"; import { mergeSessionEntry, resolveSessionLifecycleTimestamps, @@ -188,7 +186,10 @@ export async function persistAgentSessionPhase(params: { !params.isRestartRecoveryResumeRun && internalFreshEntry && (internalFreshEntry.mainRestartRecovery?.tombstone || - isMainSessionRecoveryExhausted(internalFreshEntry)) + (internalFreshEntry.status === "running" && + internalFreshEntry.abortedLastRun === true && + (internalFreshEntry.mainRestartRecovery?.chargedAttempts ?? 0) >= + MAX_RECOVERY_RETRIES)) ) { restartRecoveryReservationConflict = `Session "${params.canonicalSessionKey}" is quarantined after restart recovery ` + diff --git a/src/gateway/server-methods/chat-send-attachments.ts b/src/gateway/server-methods/chat-send-attachments.ts index 0f8f47f5812b..ae3317e5d001 100644 --- a/src/gateway/server-methods/chat-send-attachments.ts +++ b/src/gateway/server-methods/chat-send-attachments.ts @@ -14,12 +14,12 @@ import { measureDiagnosticsTimelineSpan } from "../../infra/diagnostics-timeline import { formatErrorMessage } from "../../infra/errors.js"; import { parseInboundMediaUri } from "../../media/media-reference.js"; import { deleteMediaBuffer, MEDIA_MAX_BYTES } from "../../media/store.js"; +import { resolveChatAttachmentMaxBytes } from "../chat-attachment-policy.js"; import { MediaOffloadError, type OffloadedRef, logAttachmentFailure, parseMessageWithAttachments, - resolveChatAttachmentMaxBytes, stripImageMediaMarkers, UnsupportedAttachmentError, } from "../chat-attachments.js"; diff --git a/src/gateway/server-node-events.runtime.ts b/src/gateway/server-node-events.runtime.ts index fed5907a0be2..c39b49fc69ba 100644 --- a/src/gateway/server-node-events.runtime.ts +++ b/src/gateway/server-node-events.runtime.ts @@ -20,10 +20,10 @@ export { enqueueSystemEvent } from "../infra/system-events.js"; export { deleteMediaBuffer } from "../media/store.js"; export { normalizeMainKey } from "../routing/session-key.js"; export { defaultRuntime } from "../runtime.js"; +export { resolveChatAttachmentMaxBytes } from "./chat-attachment-policy.js"; export { parseMessageWithAttachments, persistInboundImagesForTranscript, - resolveChatAttachmentMaxBytes, } from "./chat-attachments.js"; export { normalizeRpcAttachmentsToChatAttachments } from "./server-methods/attachment-normalize.js"; export { diff --git a/src/gateway/server-startup-post-attach.test.ts b/src/gateway/server-startup-post-attach.test.ts index fd11656a7f43..c7fb7ee20231 100644 --- a/src/gateway/server-startup-post-attach.test.ts +++ b/src/gateway/server-startup-post-attach.test.ts @@ -123,8 +123,11 @@ vi.mock("../agents/subagent-registry.js", () => ({ scheduleSubagentOrphanRecovery: hoisted.scheduleSubagentOrphanRecovery, })); -vi.mock("../agents/main-session-restart-recovery.js", () => ({ +vi.mock("../agents/main-session-restart-recovery-marking.js", () => ({ markStartupOrphanedMainSessionsForRecovery: hoisted.markStartupOrphanedMainSessionsForRecovery, +})); + +vi.mock("../agents/main-session-restart-recovery.js", () => ({ scheduleRestartAbortedMainSessionRecovery: hoisted.scheduleRestartAbortedMainSessionRecovery, })); @@ -1812,9 +1815,12 @@ describe("startGatewayPostAttachRuntime", () => { }); }); - it("marks startup main-session orphans before channel startup", async () => { + it("marks startup main-session orphans before model runtime and channel startup", async () => { const events: string[] = []; let releaseMarking: (() => void) | undefined; + const prewarmPrimaryModel = vi.fn(async () => { + events.push("model-runtime"); + }); const startChannels = vi.fn(async () => { events.push("channels"); }); @@ -1835,6 +1841,7 @@ describe("startGatewayPostAttachRuntime", () => { defaultWorkspaceDir: "/tmp/openclaw-workspace", deps: {} as never, startChannels, + prewarmPrimaryModel, log: { warn: vi.fn() }, logHooks: { info: vi.fn(), @@ -1858,11 +1865,54 @@ describe("startGatewayPostAttachRuntime", () => { releaseMarking(); await sidecars; - expect(events).toEqual(["main-session-mark:start", "main-session-mark:done", "channels"]); + expect(events).toEqual([ + "main-session-mark:start", + "main-session-mark:done", + "model-runtime", + "channels", + ]); + expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1); expect(startChannels).toHaveBeenCalledTimes(1); expect(hoisted.scheduleRestartAbortedMainSessionRecovery).not.toHaveBeenCalled(); }); + it("marks startup main-session orphans before propagating model runtime failure", async () => { + const modelRuntimeError = new Error("model runtime unavailable"); + const startChannels = vi.fn(async () => {}); + const prewarmPrimaryModel = vi.fn(async () => { + throw modelRuntimeError; + }); + hoisted.markStartupOrphanedMainSessionsForRecovery.mockResolvedValueOnce({ + marked: 1, + skipped: 0, + }); + + await expect( + startGatewaySidecars({ + cfg: { hooks: { internal: { enabled: false } } } as never, + pluginRegistry: createPostAttachParams().pluginRegistry, + defaultWorkspaceDir: "/tmp/openclaw-workspace", + deps: {} as never, + startChannels, + prewarmPrimaryModel, + log: { warn: vi.fn() }, + logHooks: { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }, + logChannels: { + info: vi.fn(), + error: vi.fn(), + }, + }), + ).rejects.toBe(modelRuntimeError); + + expect(hoisted.markStartupOrphanedMainSessionsForRecovery).toHaveBeenCalledTimes(1); + expect(prewarmPrimaryModel).toHaveBeenCalledTimes(1); + expect(startChannels).not.toHaveBeenCalled(); + }); + it("logs startup main-session marker failures and still starts channels", async () => { const log = { warn: vi.fn() }; const startChannels = vi.fn(async () => {}); diff --git a/src/gateway/server-startup-post-attach.ts b/src/gateway/server-startup-post-attach.ts index d3071265911e..c8cbe4f16ce6 100644 --- a/src/gateway/server-startup-post-attach.ts +++ b/src/gateway/server-startup-post-attach.ts @@ -55,6 +55,10 @@ type GatewayMemoryStartupPolicy = const loadMainSessionRestartRecoveryModule = createLazyRuntimeModule( () => import("../agents/main-session-restart-recovery.js"), ); +// Startup only needs orphan marking; keep resume and delivery runtime out of the pre-channel path. +const loadMainSessionRestartRecoveryMarkingModule = createLazyRuntimeModule( + () => import("../agents/main-session-restart-recovery-marking.js"), +); const loadAgentDefaultsModule = createLazyRuntimeModule(() => import("../agents/defaults.js")); @@ -658,6 +662,24 @@ export async function startGatewaySidecars(params: { const skipChannels = isTruthyEnvValue(process.env.OPENCLAW_SKIP_CHANNELS) || isTruthyEnvValue(process.env.OPENCLAW_SKIP_PROVIDERS); + // These runs were orphaned by the previous Gateway lifecycle. Record that fact + // even if this process later fails model preparation and never starts channels. + await measureStartup(params.startupTrace, "sidecars.main-session-recovery", async () => { + try { + const { markStartupOrphanedMainSessionsForRecovery } = await measureStartup( + params.startupTrace, + "sidecars.main-session-recovery-load", + loadMainSessionRestartRecoveryMarkingModule, + ); + await measureStartup(params.startupTrace, "sidecars.main-session-recovery-scan", () => + markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg }), + ); + } catch (err) { + params.log.warn( + `main-session startup orphan marking failed before channel startup: ${String(err)}`, + ); + } + }); // Agent RPC remains available when transports are disabled. Publish configured/static facts before // accepting work; live provider catalogs stay advisory and never enter the Gateway lifecycle. await measureStartup(params.startupTrace, "sidecars.model-runtime", () => @@ -671,17 +693,6 @@ export async function startGatewaySidecars(params: { params.prewarmPrimaryModel, ), ); - await measureStartup(params.startupTrace, "sidecars.main-session-recovery", async () => { - try { - const { markStartupOrphanedMainSessionsForRecovery } = - await loadMainSessionRestartRecoveryModule(); - await markStartupOrphanedMainSessionsForRecovery({ cfg: params.cfg }); - } catch (err) { - params.log.warn( - `main-session startup orphan marking failed before channel startup: ${String(err)}`, - ); - } - }); await measureStartup(params.startupTrace, "sidecars.channels", async () => { if (!skipChannels) { try { diff --git a/src/gateway/server.hello-attachments.test.ts b/src/gateway/server.hello-attachments.test.ts new file mode 100644 index 000000000000..3da69d195e3c --- /dev/null +++ b/src/gateway/server.hello-attachments.test.ts @@ -0,0 +1,62 @@ +// Handshake coverage for the additive chat-attachment limits on `hello-ok`, so +// clients can validate a file before sending instead of hardcoding guesses. +import { MAX_IMAGE_BYTES } from "@openclaw/media-core/constants"; +import { afterAll, beforeAll, describe, expect, test } from "vitest"; +import { clearConfigCache, clearRuntimeConfigSnapshot } from "../config/config.js"; +import { + connectOk, + createGatewaySuiteHarness, + installGatewayTestHooks, + testState, +} from "./test-helpers.js"; + +installGatewayTestHooks({ scope: "suite" }); + +type GatewayHarness = Awaited>; +let gateway: GatewayHarness; + +type HelloPolicy = { + policy?: { attachments?: { maxBytes?: number; maxImageBytes?: number } }; +}; + +async function readAdvertisedAttachments(mediaMaxMb: number): Promise { + testState.agentConfig = { mediaMaxMb }; + // The handshake reads the pinned runtime snapshot, so drop it to pick the + // override up on the next connection. + clearConfigCache(); + clearRuntimeConfigSnapshot(); + const socket = await gateway.openWs(); + try { + const hello = (await connectOk(socket)) as HelloPolicy; + return hello.policy?.attachments; + } finally { + socket.close(); + } +} + +describe("hello-ok attachment limits", () => { + beforeAll(async () => { + gateway = await createGatewaySuiteHarness(); + }); + + afterAll(async () => { + await gateway.close(); + testState.agentConfig = undefined; + clearConfigCache(); + clearRuntimeConfigSnapshot(); + }); + + test("advertises the configured ceiling and the image hydration cap", async () => { + expect(await readAdvertisedAttachments(7)).toEqual({ + maxBytes: 7 * 1024 * 1024, + maxImageBytes: MAX_IMAGE_BYTES, + }); + }); + + test("clamps the advertised image ceiling to a smaller configured ceiling", async () => { + expect(await readAdvertisedAttachments(2)).toEqual({ + maxBytes: 2 * 1024 * 1024, + maxImageBytes: 2 * 1024 * 1024, + }); + }); +}); diff --git a/src/gateway/server/ws-connection/connect-hello.ts b/src/gateway/server/ws-connection/connect-hello.ts index 633c4f86553d..8c4ccb6a7e56 100644 --- a/src/gateway/server/ws-connection/connect-hello.ts +++ b/src/gateway/server/ws-connection/connect-hello.ts @@ -14,6 +14,7 @@ import { } from "../../../infra/node-pairing.js"; import { listProfiles } from "../../../state/user-profiles.js"; import { resolveRuntimeServiceVersion } from "../../../version.js"; +import { resolveChatAttachmentPolicy } from "../../chat-attachment-policy.js"; import { listControlUiPluginTabs, listControlUiPluginWidgetKinds, @@ -124,6 +125,7 @@ export async function sendGatewayHello( maxPayload: MAX_PAYLOAD_BYTES, maxBufferedBytes: MAX_BUFFERED_BYTES, tickIntervalMs: TICK_INTERVAL_MS, + attachments: resolveChatAttachmentPolicy(context.configSnapshot), allowedSessionVisibilities: allowedSessionVisibilities(context.configSnapshot), hasMultipleSessionSharingIdentities: listProfiles().filter((profile) => !profile.mergedInto).length >= 2, diff --git a/src/index.ts b/src/index.ts index 18fef2569bb5..da3759e00c71 100644 --- a/src/index.ts +++ b/src/index.ts @@ -97,7 +97,7 @@ if (!isMain) { } if (isMain) { - const { restoreTerminalState } = await import("../packages/terminal-core/src/restore.js"); + const { restoreRuntimeTerminalState } = await import("./runtime.js"); // Global error handlers to prevent silent crashes from unhandled rejections/exceptions. // These log the error and exit gracefully instead of crashing without trace. @@ -124,7 +124,7 @@ if (isMain) { for (const message of runFatalErrorHooks({ reason: "uncaught_exception", error })) { console.error("[openclaw]", message); } - restoreTerminalState("uncaught exception", { resumeStdinIfPaused: false }); + restoreRuntimeTerminalState("uncaught exception", { resumeStdinIfPaused: false }); process.exit(1); }); @@ -145,7 +145,7 @@ if (isMain) { for (const message of runFatalErrorHooks({ reason: "legacy_cli_failure", error: err })) { console.error("[openclaw]", message); } - restoreTerminalState("legacy cli failure", { resumeStdinIfPaused: false }); + restoreRuntimeTerminalState("legacy cli failure", { resumeStdinIfPaused: false }); process.exitCode = 1; }, }); diff --git a/src/infra/unhandled-rejections.fatal-detection.test.ts b/src/infra/unhandled-rejections.fatal-detection.test.ts index ef626866c0dd..1865d865f22a 100644 --- a/src/infra/unhandled-rejections.fatal-detection.test.ts +++ b/src/infra/unhandled-rejections.fatal-detection.test.ts @@ -2,12 +2,13 @@ import process from "node:process"; import { describe, it, expect, vi, beforeAll, afterAll, beforeEach, afterEach } from "vitest"; -const restoreTerminalStateMock = vi.hoisted(() => vi.fn()); +const restoreRuntimeTerminalStateMock = vi.hoisted(() => vi.fn()); -vi.mock("../../packages/terminal-core/src/restore.js", () => ({ - restoreTerminalState: restoreTerminalStateMock, +vi.mock("../runtime.js", () => ({ + restoreRuntimeTerminalState: restoreRuntimeTerminalStateMock, })); +import { loggingState } from "../logging/state.js"; import { resetFatalErrorHooksForTest } from "./fatal-error-hooks.js"; import { installUnhandledRejectionHandler, @@ -20,6 +21,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { let consoleErrorSpy: ReturnType; let consoleWarnSpy: ReturnType; let originalExit: typeof process.exit; + const originalForceConsoleToStderr = loggingState.forceConsoleToStderr; beforeAll(() => { originalExit = process.exit.bind(process); @@ -43,6 +45,7 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { afterEach(() => { vi.clearAllMocks(); + loggingState.forceConsoleToStderr = originalForceConsoleToStderr; consoleErrorSpy.mockRestore(); consoleWarnSpy.mockRestore(); }); @@ -71,16 +74,16 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { expectedRestoreReason?: string, ): void { exitCalls = []; - restoreTerminalStateMock.mockClear(); + restoreRuntimeTerminalStateMock.mockClear(); emitUnhandled(reason); expect(exitCalls).toEqual(expected); if (expectedRestoreReason) { - expect(restoreTerminalStateMock).toHaveBeenCalledWith(expectedRestoreReason, { + expect(restoreRuntimeTerminalStateMock).toHaveBeenCalledWith(expectedRestoreReason, { resumeStdinIfPaused: false, }); return; } - expect(restoreTerminalStateMock).not.toHaveBeenCalled(); + expect(restoreRuntimeTerminalStateMock).not.toHaveBeenCalled(); } describe("fatal errors", () => { @@ -105,6 +108,35 @@ describe("installUnhandledRejectionHandler - fatal detection", () => { "Out of memory", ); }); + + it.each([ + { + name: "fatal runtime rejection", + errorCode: "ERR_OUT_OF_MEMORY", + exitCode: 1, + restoreReason: "fatal unhandled rejection", + }, + { + name: "invalid configuration rejection", + errorCode: "INVALID_CONFIG", + exitCode: 78, + restoreReason: "configuration error", + }, + ])( + "routes $name terminal resets through the machine-aware runtime owner", + ({ errorCode, exitCode, restoreReason }) => { + loggingState.forceConsoleToStderr = true; + + emitUnhandled( + Object.assign(new Error("expected machine-output failure"), { code: errorCode }), + ); + + expect(exitCalls).toEqual([exitCode]); + expect(restoreRuntimeTerminalStateMock).toHaveBeenCalledWith(restoreReason, { + resumeStdinIfPaused: false, + }); + }, + ); }); describe("scoped uncaught exception handlers", () => { diff --git a/src/infra/unhandled-rejections.ts b/src/infra/unhandled-rejections.ts index 90701dceeacf..f16d40fa3a86 100644 --- a/src/infra/unhandled-rejections.ts +++ b/src/infra/unhandled-rejections.ts @@ -1,7 +1,7 @@ // Installs fatal and transient unhandled rejection/exception handlers. import process from "node:process"; import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce"; -import { restoreTerminalState } from "../../packages/terminal-core/src/restore.js"; +import { restoreRuntimeTerminalState } from "../runtime.js"; import { isAbortError } from "./abort-signal.js"; import { collectErrorGraphCandidates, @@ -522,7 +522,7 @@ export function installUnhandledRejectionHandler(): void { for (const message of runFatalErrorHooks({ reason: hookReason, error })) { console.error("[openclaw]", message); } - restoreTerminalState(reason, { resumeStdinIfPaused: false }); + restoreRuntimeTerminalState(reason, { resumeStdinIfPaused: false }); process.exit(exitCode); }; diff --git a/src/media/parse.test.ts b/src/media/parse.test.ts index 53ad9b154d2d..d8341b3706d4 100644 --- a/src/media/parse.test.ts +++ b/src/media/parse.test.ts @@ -49,12 +49,33 @@ describe("splitMediaFromOutput", () => { it.each([ ["/Users/pete/My File.png", "MEDIA:/Users/pete/My File.png"], ["/Users/pete/My File.png", 'MEDIA:"/Users/pete/My File.png"'], + [ + "/Users/pete/My Files/Project Assets/render final.png", + "MEDIA:/Users/pete/My Files/Project Assets/render final.png", + ], + [ + "/Users/pete/My Files/Project Assets/render final.png", + 'MEDIA:"/Users/pete/My Files/Project Assets/render final.png"', + ], + ["/tmp/album.v1/photo.png copy.png", "MEDIA:/tmp/album.v1/photo.png copy.png"], ["./screenshots/image.png", "MEDIA:./screenshots/image.png"], ["media/inbound/image.png", "MEDIA:media/inbound/image.png"], ["./screenshot.png", " MEDIA:./screenshot.png"], ["~/Pictures/My File.png", "MEDIA:~/Pictures/My File.png"], ["~/.openclaw/media/browser/snap.png", "MEDIA:~/.openclaw/media/browser/snap.png"], ["C:\\Users\\pete\\Pictures\\snap.png", "MEDIA:C:\\Users\\pete\\Pictures\\snap.png"], + [ + "C:\\Users\\First Last\\workspace\\shot.png", + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png", + ], + [ + "C:\\Users\\First Last\\workspace\\shot.png", + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png", + ], + [ + "\\\\server\\My Files\\Project Assets\\render final.png", + "MEDIA:\\\\server\\My Files\\Project Assets\\render final.png", + ], ["/tmp/tts-fAJy8C/voice-1770246885083.opus", "MEDIA:/tmp/tts-fAJy8C/voice-1770246885083.opus"], ["image.png", "MEDIA:image.png"], [ @@ -74,16 +95,56 @@ describe("splitMediaFromOutput", () => { expectAcceptedMediaPathCase(expectedPath, input); }); + it.each([ + ["MEDIA:/tmp/a.png /tmp/b.png", ["/tmp/a.png", "/tmp/b.png"]], + ["MEDIA:media/a.png media/b.png", ["media/a.png", "media/b.png"]], + ["MEDIA:/tmp/a.png media/b.png", ["/tmp/a.png", "media/b.png"]], + ["MEDIA:./a.png ./b.png", ["./a.png", "./b.png"]], + ["MEDIA:/tmp/a.png https://example.com/b.png", ["/tmp/a.png", "https://example.com/b.png"]], + [ + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png D:\\Other User\\second.png", + ["C:\\Users\\First Last\\workspace\\shot.png", "D:\\Other User\\second.png"], + ], + [ + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png media/second.png", + ["C:\\Users\\First Last\\workspace\\shot.png", "media/second.png"], + ], + [ + "MEDIA:/tmp/project screenshots/shot.png media\\second.png", + ["/tmp/project screenshots/shot.png", "media\\second.png"], + ], + [ + "MEDIA:/tmp/project screenshots/shot.png file:///tmp/second.png", + ["/tmp/project screenshots/shot.png", "/tmp/second.png"], + ], + ["MEDIA:C:\\Users\\First Last\\..\\secret.png D:\\safe\\second.png", ["D:\\safe\\second.png"]], + ["MEDIA:/tmp/project screenshots/../../.env /tmp/safe/second.png", ["/tmp/safe/second.png"]], + ] as const)("keeps separate media items on one directive line: %s", (input, mediaUrls) => { + expectParsedMediaOutputCase(input, { mediaUrls: [...mediaUrls] }); + }); + it.each([ "MEDIA:../../../etc/passwd", "MEDIA:../../.env", "MEDIA:~user/Pictures/My File.png", "MEDIA:~/Pictures/../../.ssh/id_rsa", "MEDIA:./foo/../../../etc/shadow", + "MEDIA:C:\\Users\\First Last\\..\\secret.png", + "MEDIA:/tmp/project screenshots/../../.env", ] as const)("rejects traversal and unsupported home-dir path: %s", (input) => { expectRejectedMediaPathCase(input); }); + it("does not absorb an unsafe remote URL into a spaced local media path", () => { + expectParsedMediaOutputCase( + "MEDIA:C:\\Users\\First Last\\workspace\\shot.png https://127.0.0.1/secret.png", + { + mediaUrls: ["C:\\Users\\First Last\\workspace\\shot.png"], + text: "https://127.0.0.1/secret.png", + }, + ); + }); + it.each([ "MEDIA:http://example.com/a.png", "MEDIA:https://intranet/a.png", diff --git a/src/media/parse.ts b/src/media/parse.ts index f9c934dd3cb5..c2aed9dd5fb9 100644 --- a/src/media/parse.ts +++ b/src/media/parse.ts @@ -50,6 +50,7 @@ function cleanCandidate(raw: string) { } const WINDOWS_DRIVE_RE = /^[a-zA-Z]:[\\/]/; +const MEDIA_SOURCE_ROOT_RE = /^(?:[a-z]:[\\/]|[/~]|\.{1,2}[\\/]|\\\\)/i; const SCHEME_RE = /^[a-zA-Z][a-zA-Z0-9+.-]*:/; const HAS_FILE_EXT = /\.\w{1,10}$/; @@ -198,6 +199,33 @@ function isValidMedia( return false; } +function beginsIndependentMediaSource(raw: string): boolean { + const candidate = normalizeMediaSource(cleanCandidate(raw)); + return MEDIA_SOURCE_ROOT_RE.test(candidate) || SCHEME_RE.test(candidate); +} + +function splitUnquotedMediaDirectiveParts(payload: string): string[] { + const parts: string[] = []; + let previousEnd = 0; + for (const match of payload.matchAll(/\S+/g)) { + const candidate = normalizeMediaSource(cleanCandidate(match[0])); + const previous = parts.at(-1); + const previousCandidate = previous ? normalizeMediaSource(cleanCandidate(previous)) : ""; + if ( + MEDIA_SOURCE_ROOT_RE.test(previousCandidate) && + !beginsIndependentMediaSource(candidate) && + (!HAS_FILE_EXT.test(previousCandidate) || !isValidMedia(candidate)) + ) { + // Preserve real filename whitespace while keeping independently valid attachments separate. + parts[parts.length - 1] = `${previous}${payload.slice(previousEnd, match.index)}${match[0]}`; + } else { + parts.push(match[0]); + } + previousEnd = match.index + match[0].length; + } + return parts; +} + function unwrapQuoted(value: string): string | undefined { const trimmed = value.trim(); if (trimmed.length < 2) { @@ -583,19 +611,21 @@ export function splitMediaFromOutput( const payload = expectDefined(match[1], "parse regex capture 1"); const unwrapped = unwrapQuoted(payload); const payloadValue = unwrapped ?? payload; - const parts = unwrapped ? [unwrapped] : payload.split(/\s+/).filter(Boolean); + const parts = unwrapped ? [unwrapped] : splitUnquotedMediaDirectiveParts(payload); const mediaStartIndex = media.length; let validCount = 0; const invalidParts: string[] = []; let hasValidMedia = false; for (const part of parts) { const candidate = normalizeMediaSource(cleanCandidate(part)); - if (isValidMedia(candidate, unwrapped ? { allowSpaces: true } : undefined)) { + if ( + isValidMedia(candidate, unwrapped || /\s/.test(part) ? { allowSpaces: true } : undefined) + ) { media.push(candidate); hasValidMedia = true; foundMediaToken = true; validCount += 1; - } else { + } else if (!/\s/.test(part) || !hasTraversalOrUnsupportedHomeDirPrefix(candidate)) { invalidParts.push(part); } } @@ -607,6 +637,7 @@ export function splitMediaFromOutput( !unwrapped && validCount === 1 && invalidParts.length > 0 && + !parts.slice(1).some(beginsIndependentMediaSource) && /\s/.test(payloadValue) && looksLikeLocalPath ) { diff --git a/src/model-picker/apply-session-model-selection.ts b/src/model-picker/apply-session-model-selection.ts index a6cda1ff3ed4..a5ef2f024953 100644 --- a/src/model-picker/apply-session-model-selection.ts +++ b/src/model-picker/apply-session-model-selection.ts @@ -95,11 +95,8 @@ type ApplySessionModelSelectionToEntryResult = { runtimeChange?: { kind: "clear" } | { kind: "set"; runtime: string }; }; -/** - * Applies the model transaction field family to one caller-owned snapshot. - * Mixed directives reuse this mutator and retain their single broad persistence transaction. - */ -export function applySessionModelSelectionToEntry(params: { +/** Applies the model transaction field family to one caller-owned snapshot. */ +function applySessionModelSelectionToEntry(params: { entry: SessionEntry; request: SessionModelSelectionRequest; runtime: AppliedRuntimeDirective; diff --git a/src/plugins/capability-provider-runtime.test.ts b/src/plugins/capability-provider-runtime.test.ts index 7f9ddd94cba3..9aacbec76877 100644 --- a/src/plugins/capability-provider-runtime.test.ts +++ b/src/plugins/capability-provider-runtime.test.ts @@ -80,8 +80,8 @@ vi.mock("./manifest-registry.js", async (importOriginal) => { }; }); -vi.mock("./plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot, diff --git a/src/plugins/installed-plugin-index-hash.ts b/src/plugins/installed-plugin-index-hash.ts index dce1f01186e4..f66b1a992344 100644 --- a/src/plugins/installed-plugin-index-hash.ts +++ b/src/plugins/installed-plugin-index-hash.ts @@ -59,25 +59,3 @@ export function safeFileSignature(filePath: string): InstalledPluginFileSignatur return undefined; } } - -/** Compares current file metadata with a stored installed-plugin file signature. */ -export function fileSignatureMatches( - filePath: string, - signature: InstalledPluginFileSignature | undefined, -): boolean | undefined { - if (!signature) { - return undefined; - } - if (typeof signature.ctimeMs !== "number") { - return undefined; - } - const current = safeFileSignature(filePath); - if (!current) { - return false; - } - return ( - current.size === signature.size && - current.mtimeMs === signature.mtimeMs && - current.ctimeMs === signature.ctimeMs - ); -} diff --git a/src/plugins/migration-provider-runtime.test.ts b/src/plugins/migration-provider-runtime.test.ts index ab91bc697aa0..fa710f244440 100644 --- a/src/plugins/migration-provider-runtime.test.ts +++ b/src/plugins/migration-provider-runtime.test.ts @@ -61,7 +61,7 @@ vi.mock("./active-runtime-registry.js", () => ({ }, })); -vi.mock("./plugin-registry.js", () => ({ +vi.mock("./plugin-registry-snapshot.js", () => ({ loadPluginRegistrySnapshot: mocks.loadPluginRegistrySnapshot, loadPluginRegistrySnapshotWithMetadata: mocks.loadPluginRegistrySnapshotWithMetadata, })); diff --git a/src/plugins/plugin-registry-contributions.current-snapshot.test.ts b/src/plugins/plugin-registry-contributions.current-snapshot.test.ts index c32ada20d875..fb17ac9d974b 100644 --- a/src/plugins/plugin-registry-contributions.current-snapshot.test.ts +++ b/src/plugins/plugin-registry-contributions.current-snapshot.test.ts @@ -1,5 +1,6 @@ // Verifies current plugin registry contribution snapshots. -import { afterEach, describe, expect, it } from "vitest"; +import fs from "node:fs"; +import { afterEach, describe, expect, it, vi } from "vitest"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { setCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js"; @@ -8,6 +9,7 @@ import type { InstalledPluginIndex } from "./installed-plugin-index.js"; import type { PluginManifestRecord } from "./manifest-registry.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import { loadPluginManifestRegistryForPluginRegistry } from "./plugin-registry-contributions.js"; +import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js"; afterEach(() => { clearCurrentPluginMetadataSnapshot(); @@ -141,7 +143,7 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => { expect(loadPluginManifestRegistryForPluginRegistry({ config, env }).plugins).toEqual([]); }); - it("does not reuse current metadata for explicit registry inputs or diagnostics", () => { + it("keeps explicit registry inputs authoritative and reuses current diagnostics", () => { const config: OpenClawConfig = {}; const env = { HOME: "/tmp/openclaw-test-home", @@ -190,11 +192,26 @@ describe("loadPluginManifestRegistryForPluginRegistry current snapshot", () => { }), { config, env, workspaceDir }, ); + const readDirectory = vi.spyOn(fs, "readdirSync"); + const readFile = vi.spyOn(fs, "readFileSync"); + const statFile = vi.spyOn(fs, "statSync"); expect( loadPluginManifestRegistryForPluginRegistry({ config, env, workspaceDir }).plugins.map( (plugin) => plugin.id, ), - ).toEqual([]); + ).toEqual(["enabled"]); + expect( + loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }).diagnostics, + ).toEqual([ + { + level: "info", + code: "persisted-registry-missing", + message: "missing", + }, + ]); + expect(readDirectory).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + expect(statFile).not.toHaveBeenCalled(); }); }); diff --git a/src/plugins/plugin-registry-snapshot.lifecycle.test.ts b/src/plugins/plugin-registry-snapshot.lifecycle.test.ts deleted file mode 100644 index 92c662cb16c4..000000000000 --- a/src/plugins/plugin-registry-snapshot.lifecycle.test.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { - getCurrentPluginMetadataSnapshotState, - setCurrentPluginMetadataSnapshotState, -} from "./current-plugin-metadata-state.js"; -import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; -import "./plugin-registry-snapshot.js"; - -vi.mock("./current-plugin-metadata-snapshot.js", () => ({ - getCurrentPluginMetadataSnapshot: vi.fn(() => undefined), -})); - -afterEach(() => { - clearPluginMetadataLifecycleCaches(); -}); - -describe("plugin registry snapshot lifecycle", () => { - it("clears registry metadata when the snapshot facade is mocked", () => { - setCurrentPluginMetadataSnapshotState({ plugins: [] }, "mocked-snapshot-facade"); - - expect(() => clearPluginMetadataLifecycleCaches()).not.toThrow(); - expect(getCurrentPluginMetadataSnapshotState().snapshot).toBeUndefined(); - }); -}); diff --git a/src/plugins/plugin-registry-snapshot.test.ts b/src/plugins/plugin-registry-snapshot.test.ts index 31b741432694..360c80bff4e8 100644 --- a/src/plugins/plugin-registry-snapshot.test.ts +++ b/src/plugins/plugin-registry-snapshot.test.ts @@ -16,7 +16,6 @@ import { } from "./installed-plugin-index.js"; import { markRetainedManagedNpmInstall } from "./managed-npm-retention.js"; import { loadPluginManifestRegistryForInstalledIndex } from "./manifest-registry-installed.js"; -import { clearPluginMetadataLifecycleCaches } from "./plugin-metadata-lifecycle.js"; import type { PluginMetadataSnapshot } from "./plugin-metadata-snapshot.types.js"; import { loadPluginRegistrySnapshotWithMetadata } from "./plugin-registry-snapshot.js"; import { cleanupTrackedTempDirs, makeTrackedTempDir } from "./test-helpers/fs-fixtures.js"; @@ -275,7 +274,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { }); }); - it("does not treat diagnostic current metadata as provided registry input", () => { + it("reuses diagnostic current metadata without promoting its registry source", () => { const env = { ...createHermeticEnv(makeTempDir()), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1", @@ -300,6 +299,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { configFingerprint: "", workspaceDir, index, + registrySource: "derived", registryDiagnostics: [ { level: "info", @@ -333,10 +333,27 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { }, { config, env, workspaceDir }, ); + const readDirectory = vi.spyOn(fs, "readdirSync"); + const readFile = vi.spyOn(fs, "readFileSync"); + const statFile = vi.spyOn(fs, "statSync"); const result = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }); - expect(result.source).not.toBe("provided"); + expect(result).toEqual({ + snapshot: index, + source: "derived", + diagnostics: [ + { + level: "info", + code: "persisted-registry-missing", + message: "missing", + }, + ], + manifestRegistry: { plugins: [], diagnostics: [] }, + }); + expect(readDirectory).not.toHaveBeenCalled(); + expect(readFile).not.toHaveBeenCalled(); + expect(statFile).not.toHaveBeenCalled(); }); it("does not reuse current metadata when explicit derivation inputs are supplied", () => { @@ -559,75 +576,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.diagnostics).toStrictEqual([]); }); - it("reuses a memoized registry without polling plugin files", () => { - const tempRoot = makeTempDir(); - const workspaceDir = path.join(tempRoot, "workspace"); - const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; - const config = {}; - const first = loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir }); - const readDirectory = vi.spyOn(fs, "readdirSync"); - const readFile = vi.spyOn(fs, "readFileSync"); - const statFile = vi.spyOn(fs, "statSync"); - - expect(loadPluginRegistrySnapshotWithMetadata({ config, env, workspaceDir })).toBe(first); - expect(readDirectory).not.toHaveBeenCalled(); - expect(readFile).not.toHaveBeenCalled(); - expect(statFile).not.toHaveBeenCalled(); - }); - - it("retains only the current process-lifecycle registry graph", () => { - const tempRoot = makeTempDir(); - const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; - const firstWorkspace = path.join(tempRoot, "first-workspace"); - const secondWorkspace = path.join(tempRoot, "second-workspace"); - - const first = loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: firstWorkspace, - }); - const second = loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: secondWorkspace, - }); - const refreshedFirst = loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: firstWorkspace, - }); - - expect(second).not.toBe(first); - expect(refreshedFirst).not.toBe(first); - expect( - loadPluginRegistrySnapshotWithMetadata({ - config: {}, - env, - workspaceDir: firstWorkspace, - }), - ).toBe(refreshedFirst); - }); - - it("refreshes workspace plugin discovery on explicit metadata invalidation", () => { - const tempRoot = makeTempDir(); - const workspaceDir = path.join(tempRoot, "workspace"); - const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; - - const first = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir }); - expect(first.snapshot.plugins.map((plugin) => plugin.pluginId)).not.toContain("demo"); - - writePackagePlugin(path.join(workspaceDir, ".openclaw", "extensions", "demo")); - - const second = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir }); - expect(second).toBe(first); - - clearPluginMetadataLifecycleCaches(); - - const refreshed = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, workspaceDir }); - expect(refreshed.snapshot.plugins.map((plugin) => plugin.pluginId)).toContain("demo"); - }); - - it("ignores malformed load paths while memoizing snapshots", () => { + it("ignores malformed load paths while deriving snapshots", () => { const tempRoot = makeTempDir(); const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; const config = { @@ -673,6 +622,36 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.diagnostics).toStrictEqual([]); }); + it("rebuilds when an explicit candidate moves identical package metadata", () => { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const packageContents = JSON.stringify({ name: "demo", version: "1.0.0" }); + const baseCandidate = createCandidate(rootDir); + fs.writeFileSync(path.join(rootDir, "package.json"), packageContents, "utf8"); + const persisted = loadInstalledPluginIndex({ + candidates: [{ ...baseCandidate, packageDir: rootDir }], + config: {}, + env, + }); + writePersistedInstalledPluginIndexSync(persisted, { stateDir }); + const nestedPackageDir = path.join(rootDir, "nested"); + fs.mkdirSync(nestedPackageDir, { recursive: true }); + fs.writeFileSync(path.join(nestedPackageDir, "package.json"), packageContents, "utf8"); + + const result = loadPluginRegistrySnapshotWithMetadata({ + candidates: [{ ...baseCandidate, packageDir: nestedPackageDir }], + config: {}, + env, + stateDir, + }); + + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + expect(result.snapshot.plugins[0]?.packageJson?.path).toBe("nested/package.json"); + }); + it("derives a complete index when a configured load-path plugin is missing", () => { const tempRoot = makeTempDir(); const firstRoot = path.join(tempRoot, "first"); @@ -810,7 +789,24 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { const metaDir = path.join(rootDir, "..meta"); fs.mkdirSync(metaDir, { recursive: true }); const packageJsonPath = path.join(metaDir, "package.json"); - fs.writeFileSync(packageJsonPath, JSON.stringify({ name: "demo", version: "1.0.0" }), "utf8"); + fs.writeFileSync( + packageJsonPath, + JSON.stringify({ + name: "demo", + version: "1.0.0", + openclaw: { + channel: { + id: "demo", + label: "Demo", + commands: { + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }, + }, + }, + }), + "utf8", + ); const index = loadInstalledPluginIndex({ config, env }); const [plugin] = index.plugins; if (!plugin) { @@ -842,6 +838,17 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.source).toBe("persisted"); expect(result.diagnostics).toStrictEqual([]); + expect(result.manifestRegistry).toBeUndefined(); + const registry = loadPluginManifestRegistryForInstalledIndex({ + index: result.snapshot, + config, + env, + includeDisabled: true, + }); + expect(registry.plugins[0]?.channelCatalogMeta?.commands).toEqual({ + nativeCommandsAutoEnabled: true, + nativeSkillsAutoEnabled: false, + }); }); it.runIf(process.platform !== "win32")( @@ -857,6 +864,7 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { const config = { plugins: { load: { paths: [rootDir] }, + entries: { demo: { enabled: false } }, }, }; writePackagePlugin(rootDir); @@ -902,6 +910,72 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { }, ); + it.runIf(process.platform !== "win32")( + "rejects dangling root, source, and manifest links for disabled records", + () => { + for (const artifact of ["root", "source", "manifest"] as const) { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + plugins: { + load: { paths: [rootDir] }, + entries: { demo: { enabled: false } }, + }, + }; + writePackagePlugin(rootDir); + writePersistedInstalledPluginIndexSync(loadInstalledPluginIndex({ config, env }), { + stateDir, + }); + const artifactPath = + artifact === "root" + ? rootDir + : path.join(rootDir, artifact === "source" ? "index.ts" : "openclaw.plugin.json"); + fs.rmSync(artifactPath, { recursive: artifact === "root" }); + fs.symlinkSync(path.join(tempRoot, "missing"), artifactPath); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + expect([artifact, result.source]).toEqual([artifact, "derived"]); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + } + }, + ); + + it("rejects escaped missing package metadata for disabled records", () => { + const tempRoot = makeTempDir(); + const rootDir = path.join(tempRoot, "workspace"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + plugins: { + load: { paths: [rootDir] }, + entries: { demo: { enabled: false } }, + }, + }; + writePackagePlugin(rootDir); + const index = loadInstalledPluginIndex({ config, env }); + const plugin = requirePluginRecord(index.plugins, "demo"); + writePersistedInstalledPluginIndexSync( + { + ...index, + plugins: [ + { + ...plugin, + packageJson: { path: "../gone/package.json", hash: "missing" }, + }, + ], + }, + { stateDir }, + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + }); + it("detects same-size same-mtime manifest replacements", () => { const tempRoot = makeTempDir(); const rootDir = path.join(tempRoot, "workspace"); @@ -1048,10 +1122,10 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["codex", "whatsapp"]); }); - it("resolves a persisted bundled root only once per registry load", () => { + it("keeps missing disabled bundled records under the trusted bundled root", () => { const tempRoot = makeTempDir(); - const packageRoot = path.join(tempRoot, "openclaw"); - const bundledRoot = path.join(packageRoot, "dist", "extensions"); + const bundledRoot = path.join(tempRoot, "dist", "extensions"); + const pluginRoot = path.join(bundledRoot, "whatsapp"); const stateDir = path.join(tempRoot, "state"); const env = { OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, @@ -1059,22 +1133,41 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { OPENCLAW_VERSION: "2026.4.26", VITEST: "true", }; - const pluginIds = ["bundled-one", "bundled-two", "bundled-three", "bundled-four"]; - - for (const pluginId of pluginIds) { - writeBundledPlugin(path.join(bundledRoot, pluginId), pluginId, "index.js"); - } - const index = loadInstalledPluginIndex({ config: {}, env, stateDir }); + const config = { plugins: { entries: { whatsapp: { enabled: false } } } }; + writeBundledPlugin(pluginRoot, "whatsapp", "index.js"); + const index = loadInstalledPluginIndex({ config, env, stateDir }); writePersistedInstalledPluginIndexSync(index, { stateDir }); - const realpathSpy = vi.spyOn(fs, "realpathSync"); + fs.rmSync(pluginRoot, { recursive: true }); - const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir }); + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); expect(result.source).toBe("persisted"); - expect(result.snapshot.plugins.map((plugin) => plugin.pluginId).toSorted()).toEqual( - pluginIds.toSorted(), - ); - expect(realpathSpy.mock.calls.filter(([filePath]) => filePath === bundledRoot)).toHaveLength(1); + expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["whatsapp"]); + expect(result.snapshot.plugins[0]?.enabled).toBe(false); + }); + + it("keeps missing disabled inventory beside unchanged configured plugins", () => { + const tempRoot = makeTempDir(); + const liveRoot = path.join(tempRoot, "live"); + const missingRoot = path.join(tempRoot, "missing"); + const stateDir = path.join(tempRoot, "state"); + const env = { ...createHermeticEnv(tempRoot), OPENCLAW_DISABLE_BUNDLED_PLUGINS: "1" }; + const config = { + plugins: { + load: { paths: [liveRoot, missingRoot] }, + entries: { missing: { enabled: false } }, + }, + }; + writePackagePlugin(liveRoot, { pluginId: "live" }); + writePackagePlugin(missingRoot, { pluginId: "missing" }); + const index = loadInstalledPluginIndex({ config, env }); + writePersistedInstalledPluginIndexSync(index, { stateDir }); + fs.rmSync(missingRoot, { recursive: true }); + + const result = loadPluginRegistrySnapshotWithMetadata({ config, env, stateDir }); + + expect(result.source).toBe("persisted"); + expect(result.snapshot.plugins.map((plugin) => plugin.pluginId)).toEqual(["live", "missing"]); }); it("treats a persisted source bundled root as stale once its built peer appears", () => { @@ -1112,6 +1205,49 @@ describe("loadPluginRegistrySnapshotWithMetadata", () => { ]); }); + it("replaces a persisted built root when its source plugin opts out of bundled output", () => { + const tempRoot = makeTempDir(); + const packageRoot = path.join(tempRoot, "openclaw"); + const bundledRoot = path.join(packageRoot, "dist", "extensions"); + const sourcePluginDir = path.join(packageRoot, "extensions", "whatsapp"); + const stateDir = path.join(tempRoot, "state"); + const env = { + OPENCLAW_BUNDLED_PLUGINS_DIR: bundledRoot, + OPENCLAW_STATE_DIR: stateDir, + OPENCLAW_VERSION: "2026.4.26", + VITEST: "true", + }; + + fs.mkdirSync(path.join(packageRoot, "src"), { recursive: true }); + fs.writeFileSync(path.join(packageRoot, ".git"), "gitdir: /tmp/mock\n", "utf8"); + fs.writeFileSync(path.join(packageRoot, "pnpm-workspace.yaml"), "packages: []\n", "utf8"); + writeBundledPlugin(sourcePluginDir, "whatsapp", "index.ts"); + writeBundledPlugin(path.join(bundledRoot, "whatsapp"), "whatsapp", "index.js"); + + const builtIndex = loadInstalledPluginIndex({ config: {}, env, stateDir }); + expect(builtIndex.plugins.map((plugin) => plugin.rootDir)).toEqual([ + fs.realpathSync(path.join(bundledRoot, "whatsapp")), + ]); + writePersistedInstalledPluginIndexSync(builtIndex, { stateDir }); + fs.writeFileSync( + path.join(sourcePluginDir, "package.json"), + JSON.stringify({ + name: "@openclaw/whatsapp", + version: "1.0.0", + openclaw: { extensions: ["./index.ts"], build: { bundledDist: false } }, + }), + "utf8", + ); + + const result = loadPluginRegistrySnapshotWithMetadata({ config: {}, env, stateDir }); + + expect(result.source).toBe("derived"); + expectDiagnosticsContainCode(result.diagnostics, "persisted-registry-stale-source"); + expect(result.snapshot.plugins.map((plugin) => plugin.rootDir)).toEqual([ + fs.realpathSync(sourcePluginDir), + ]); + }); + it("keeps a persisted bind-mounted source overlay when its built peer exists", () => { const tempRoot = makeTempDir(); const packageRoot = path.join(tempRoot, "openclaw"); diff --git a/src/plugins/plugin-registry-snapshot.ts b/src/plugins/plugin-registry-snapshot.ts index 147590d83bb6..151692c971c4 100644 --- a/src/plugins/plugin-registry-snapshot.ts +++ b/src/plugins/plugin-registry-snapshot.ts @@ -1,20 +1,16 @@ // Builds stable snapshots of plugin registry contributions. -import crypto from "node:crypto"; import fs from "node:fs"; import path from "node:path"; +import { isDeepStrictEqual } from "node:util"; import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../agents/agent-scope-config.js"; import { tryReadJsonSync } from "../infra/json-files.js"; -import { resolveUserPath } from "../utils.js"; -import { resolveCompatibilityHostVersion } from "../version.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; import { buildLegacyBundledRootPath } from "./bundled-load-path-aliases.js"; import { listBundledSourceOverlayDirs } from "./bundled-source-overlays.js"; import { normalizePluginsConfig } from "./config-state.js"; import { getCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-snapshot.js"; -import { clearCurrentPluginMetadataSnapshot } from "./current-plugin-metadata-state.js"; -import { discoverConfiguredPluginLoadPaths, type PluginDiscoveryResult } from "./discovery.js"; -import { resolveActivePluginInstallRoots } from "./install-root-context.js"; -import { fileSignatureMatches, hashJson } from "./installed-plugin-index-hash.js"; +import type { PluginDiscoveryResult } from "./discovery.js"; +import { safeFileSignature, safeHashFile } from "./installed-plugin-index-hash.js"; import { hasOptionalMissingPluginManifestFile } from "./installed-plugin-index-manifest.js"; import { loadInstalledPluginIndexInstallRecordsSync } from "./installed-plugin-index-record-reader.js"; import { @@ -26,7 +22,6 @@ import { } from "./installed-plugin-index-store.js"; import { getInstalledPluginRecord, - extractPluginInstallRecordsFromInstalledPluginIndex, hasMissingConfigPathActivationMetadata, isInstalledPluginEnabled, loadInstalledPluginIndexWithDiscovery, @@ -36,12 +31,67 @@ import { type LoadInstalledPluginIndexParams, type RefreshInstalledPluginIndexParams, } from "./installed-plugin-index.js"; -import { loadPluginManifestRegistry, type PluginManifestRegistry } from "./manifest-registry.js"; +import type { PluginManifestRegistry } from "./manifest-registry.js"; import { getPackageManifestMetadata, type PackageManifest } from "./manifest.js"; -import { safeRealpathSync } from "./path-safety.js"; -import { registerPluginMetadataProcessMemoLifecycleClear } from "./plugin-metadata-lifecycle.js"; +import { isPathInside, safeRealpathSync } from "./path-safety.js"; import type { PluginRegistrySnapshotSource } from "./plugin-registry-snapshot.types.js"; +function resolvePluginRegistryContent( + index: InstalledPluginIndex, + comparePackageJsonPath: boolean, + excludedPlugins?: ReadonlyMap, +): unknown { + const { + generatedAtMs: _generatedAtMs, + refreshReason: _refreshReason, + warning: _warning, + ...content + } = index; + const excludedRoots = [...(excludedPlugins?.values() ?? [])].map((root) => path.resolve(root)); + const exclusionPathCache = new Map(); + return { + ...content, + diagnostics: excludedPlugins + ? content.diagnostics.filter( + (diagnostic) => + !( + (diagnostic.pluginId && excludedPlugins.has(diagnostic.pluginId)) || + (diagnostic.source && + excludedRoots.some((root) => + isContainedPluginPath(root, diagnostic.source!, exclusionPathCache), + )) + ), + ) + : content.diagnostics, + installRecords: excludedPlugins + ? Object.fromEntries( + Object.entries(content.installRecords).filter( + ([pluginId]) => !excludedPlugins.has(pluginId), + ), + ) + : content.installRecords, + plugins: content.plugins + .filter((plugin) => !excludedPlugins?.has(plugin.pluginId)) + .map((plugin) => { + const { manifestFile: _manifestFile, packageJson, ...record } = plugin; + if (!packageJson) { + return record; + } + if (!comparePackageJsonPath) { + return record; + } + const { + fileSignature: _fileSignature, + path: packageJsonPath, + ...stablePackageJson + } = packageJson; + return Object.assign(record, { + packageJson: Object.assign(stablePackageJson, { path: packageJsonPath }), + }); + }), + }; +} + export type PluginRegistrySnapshot = InstalledPluginIndex; export type PluginRegistryRecord = InstalledPluginIndexRecord; type PluginRegistryInspection = InstalledPluginIndexStoreInspection; @@ -65,36 +115,6 @@ type PluginRegistrySnapshotResult = { manifestRegistry?: PluginManifestRegistry; }; -const REGISTRY_SNAPSHOT_MEMO_ENV_KEYS = [ - "APPDATA", - "HOME", - "OPENCLAW_BUNDLED_PLUGINS_DIR", - "OPENCLAW_COMPATIBILITY_HOST_VERSION", - "OPENCLAW_CONFIG_PATH", - "OPENCLAW_DISABLE_BUNDLED_PLUGINS", - "OPENCLAW_DISABLE_BUNDLED_SOURCE_OVERLAYS", - "OPENCLAW_HOME", - "OPENCLAW_NIX_MODE", - "OPENCLAW_STATE_DIR", - "USERPROFILE", - "XDG_CONFIG_HOME", -] as const; - -type PluginRegistrySnapshotMemo = { - key: string; - result: PluginRegistrySnapshotResult; -}; - -let pluginRegistrySnapshotMemo: PluginRegistrySnapshotMemo | undefined; - -function clearLoadPluginRegistrySnapshotMemo(): void { - pluginRegistrySnapshotMemo = undefined; - // A retired registry must not leave its published metadata graph behind. - clearCurrentPluginMetadataSnapshot(); -} - -registerPluginMetadataProcessMemoLifecycleClear(clearLoadPluginRegistrySnapshotMemo); - export type LoadPluginRegistryParams = LoadInstalledPluginIndexParams & InstalledPluginIndexStoreOptions & { index?: PluginRegistrySnapshot; @@ -105,68 +125,6 @@ type GetPluginRecordParams = LoadPluginRegistryParams & { pluginId: string; }; -function pickRegistrySnapshotMemoEnv(env: NodeJS.ProcessEnv): Record { - return Object.fromEntries( - REGISTRY_SNAPSHOT_MEMO_ENV_KEYS.flatMap((key) => { - const value = env[key]; - return value === undefined ? [] : [[key, value]]; - }), - ); -} - -function canMemoizePluginRegistrySnapshot(params: LoadPluginRegistryParams): boolean { - return ( - params.index === undefined && - params.candidates === undefined && - params.diagnostics === undefined && - params.discovery === undefined && - params.installRecords === undefined && - params.now === undefined && - params.filePath === undefined && - params.pluginIndexFilePath === undefined - ); -} - -function resolvePluginRegistrySnapshotMemoKey( - params: LoadPluginRegistryParams, - env: NodeJS.ProcessEnv, -): string | undefined { - if (!canMemoizePluginRegistrySnapshot(params)) { - return undefined; - } - return hashJson({ - config: params.config ?? null, - cwd: process.cwd(), - env: pickRegistrySnapshotMemoEnv(env), - installRoots: resolveActivePluginInstallRoots(env), - hostContractVersion: resolveCompatibilityHostVersion(env), - preferPersisted: params.preferPersisted ?? null, - // Install, reload, and persisted-index writes clear this memo explicitly. - // Polling roots or SQLite here would put discovery back on every hot lookup. - stateDir: params.stateDir ? resolveUserPath(params.stateDir, env) : null, - workspaceDir: params.workspaceDir ? resolveUserPath(params.workspaceDir, env) : null, - }); -} - -function findPluginRegistrySnapshotMemo( - key: string | undefined, -): PluginRegistrySnapshotResult | undefined { - return key && pluginRegistrySnapshotMemo?.key === key - ? pluginRegistrySnapshotMemo.result - : undefined; -} - -function rememberPluginRegistrySnapshotMemo( - key: string | undefined, - result: PluginRegistrySnapshotResult, -): PluginRegistrySnapshotResult { - if (!key) { - return result; - } - pluginRegistrySnapshotMemo = { key, result }; - return result; -} - function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams): boolean { return ( params.preferPersisted !== false && @@ -176,6 +134,7 @@ function canReuseCurrentPluginMetadataSnapshot(params: LoadPluginRegistryParams) params.installRecords === undefined && params.candidates === undefined && params.diagnostics === undefined && + params.discovery === undefined && params.now === undefined ); } @@ -186,266 +145,194 @@ function loadCurrentPluginRegistrySnapshotResult( if (!canReuseCurrentPluginMetadataSnapshot(params)) { return undefined; } - const env = params.env ?? process.env; const current = getCurrentPluginMetadataSnapshot({ config: params.config, - env, + env: params.env ?? process.env, ...(params.workspaceDir ? { workspaceDir: params.workspaceDir } : {}), }); - if (!current || current.registryDiagnostics.length > 0) { + if (!current) { return undefined; } return { snapshot: current.index, - source: "provided", + source: + current.registrySource ?? (current.registryDiagnostics.length > 0 ? "derived" : "provided"), diagnostics: current.registryDiagnostics, + ...(current.discovery ? { discovery: current.discovery } : {}), manifestRegistry: current.manifestRegistry, }; } -function hasMissingPersistedPluginSource(index: InstalledPluginIndex): boolean { +function fileContentMatches( + filePath: string, + hash: string, + signature?: InstalledPluginIndexRecord["manifestFile"], + trustSignature = true, +): boolean { + const current = safeFileSignature(filePath); + if (!current) { + return false; + } + if ( + trustSignature && + signature?.ctimeMs !== undefined && + current.size === signature.size && + current.mtimeMs === signature.mtimeMs && + current.ctimeMs === signature.ctimeMs + ) { + return true; + } + return safeHashFile({ filePath, diagnostics: [], required: false }) === hash; +} + +function isContainedPluginPath( + rootPath: string, + targetPath: string, + cache: Map, +): boolean { + // Project unresolved suffixes from the nearest real ancestor so missing disabled + // artifacts stay inspectable without accepting symlink or path-alias escapes. + const resolveProjectedPath = (inputPath: string): string | null => { + const target = path.resolve(inputPath); + for (let cursor = target; ; cursor = path.dirname(cursor)) { + try { + fs.lstatSync(cursor); + const realCursor = safeRealpathSync(cursor, cache); + return realCursor ? path.resolve(realCursor, path.relative(cursor, target)) : null; + } catch { + if (cursor === path.dirname(cursor)) { + return null; + } + } + } + }; + const root = resolveProjectedPath(rootPath); + const target = resolveProjectedPath(targetPath); + return Boolean(root && target && isPathInside(root, target)); +} + +function hasStalePersistedPluginFiles(index: InstalledPluginIndex): boolean { + const realpathCache = new Map(); return index.plugins.some((plugin) => { - if (!plugin.enabled) { + if (!isContainedPluginPath(plugin.rootDir, plugin.rootDir, realpathCache)) { + return true; + } + if (!fs.existsSync(plugin.rootDir) && plugin.enabled) { + return true; + } + for (const artifactPath of [plugin.source, plugin.setupSource, plugin.manifestPath]) { + if (artifactPath && !isContainedPluginPath(plugin.rootDir, artifactPath, realpathCache)) { + return true; + } + } + if ( + plugin.enabled && + ((plugin.source ? !fs.existsSync(plugin.source) : false) || + (plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false)) + ) { + return true; + } + if (!hasOptionalMissingPluginManifestFile(plugin)) { + if (!fs.existsSync(plugin.manifestPath)) { + if (plugin.enabled) { + return true; + } + } else if ( + !fileContentMatches(plugin.manifestPath, plugin.manifestHash, plugin.manifestFile) + ) { + return true; + } + } + if (!plugin.packageJson) { return false; } - return ( - !fs.existsSync(plugin.rootDir) || - (!hasOptionalMissingPluginManifestFile(plugin) && !fs.existsSync(plugin.manifestPath)) || - (plugin.source ? !fs.existsSync(plugin.source) : false) || - (plugin.setupSource ? !fs.existsSync(plugin.setupSource) : false) + const packageJsonPath = path.resolve(plugin.rootDir, plugin.packageJson.path); + if (!isContainedPluginPath(plugin.rootDir, packageJsonPath, realpathCache)) { + return true; + } + if (!fs.existsSync(packageJsonPath)) { + return plugin.enabled; + } + if (!isRealPathInside(plugin.rootDir, packageJsonPath, realpathCache)) { + return true; + } + return !fileContentMatches( + packageJsonPath, + plugin.packageJson.hash, + plugin.packageJson.fileSignature, + plugin.origin === "bundled", ); }); } -function hasMismatchedPersistedConfigPathPlugins( - index: InstalledPluginIndex, - params: LoadPluginRegistryParams, - env: NodeJS.ProcessEnv, - realpathCache: Map, -): boolean { - const loadPaths = normalizePluginsConfig(params.config?.plugins).loadPaths; - const discovery = discoverConfiguredPluginLoadPaths({ - loadPaths, - workspaceDir: params.workspaceDir, - env, - }); - const configuredRoots = loadPluginManifestRegistry({ - config: params.config, - workspaceDir: params.workspaceDir, - env, - candidates: discovery.candidates, - diagnostics: discovery.diagnostics, - installRecords: extractPluginInstallRecordsFromInstalledPluginIndex(index), - }).plugins.map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache)); - const persistedRoots = index.plugins - .filter((plugin) => plugin.origin === "config") - .map((plugin) => resolveComparablePath(plugin.rootDir, realpathCache)); - if (configuredRoots.length !== persistedRoots.length) { - return true; - } - return configuredRoots.some((rootDir, position) => rootDir !== persistedRoots[position]); -} - -function resolveComparablePath(filePath: string, realpathCache: Map): string { - return safeRealpathSync(filePath, realpathCache) ?? path.resolve(filePath); -} - -function isRelativePathInsideOrEqual(relativePath: string): boolean { - return ( - relativePath === "" || - (relativePath !== ".." && - !relativePath.startsWith(`..${path.sep}`) && - !path.isAbsolute(relativePath)) - ); -} - -function isPathInsideOrEqual( - childPath: string, +function isRealPathInside( parentPath: string, - realpathCache: Map, + childPath: string, + cache: Map, ): boolean { - const relative = path.relative( - resolveComparablePath(parentPath, realpathCache), - resolveComparablePath(childPath, realpathCache), - ); - return isRelativePathInsideOrEqual(relative); + const parent = safeRealpathSync(parentPath, cache); + const child = safeRealpathSync(childPath, cache); + return Boolean(parent && child && isPathInside(parent, child)); } -function hasMismatchedPersistedBundledPluginRoot( +function hasMismatchedPersistedBundledRoot( index: InstalledPluginIndex, env: NodeJS.ProcessEnv, - realpathCache: Map, ): boolean { - const bundledPluginsDir = resolveBundledPluginsDir(env); - if (!bundledPluginsDir) { + const bundledRoot = resolveBundledPluginsDir(env); + if (!bundledRoot) { return false; } - let sourceOverlayDirs: string[] | undefined; + const realpathCache = new Map(); + const overlays = listBundledSourceOverlayDirs({ bundledRoot, env }); + const legacyRoot = buildLegacyBundledRootPath(bundledRoot); + const sourceCheckout = + legacyRoot && + fs.existsSync(path.join(path.dirname(legacyRoot), ".git")) && + fs.existsSync(path.join(path.dirname(legacyRoot), "pnpm-workspace.yaml")) && + fs.existsSync(path.join(path.dirname(legacyRoot), "src")); return index.plugins.some((plugin) => { if (plugin.origin !== "bundled") { return false; } - sourceOverlayDirs ??= listBundledSourceOverlayDirs({ - bundledRoot: bundledPluginsDir, - env, - }); - return !isAllowedPersistedBundledPluginRoot( - plugin, - bundledPluginsDir, - sourceOverlayDirs, - realpathCache, - ); - }); -} - -function isAllowedPersistedBundledPluginRoot( - plugin: InstalledPluginIndexRecord, - bundledPluginsDir: string, - sourceOverlayDirs: readonly string[], - realpathCache: Map, -): boolean { - const pluginRootDir = plugin.rootDir; - const legacyRoot = buildLegacyBundledRootPath(bundledPluginsDir); - if (isPathInsideOrEqual(pluginRootDir, bundledPluginsDir, realpathCache)) { - if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) { - return true; - } - const relativePluginRoot = path.relative( - resolveComparablePath(bundledPluginsDir, realpathCache), - resolveComparablePath(pluginRootDir, realpathCache), - ); - return !sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot)); - } - if ( - sourceOverlayDirs.some((overlayDir) => - isPathInsideOrEqual(pluginRootDir, overlayDir, realpathCache), - ) - ) { - return true; - } - if (!legacyRoot || !isSourceCheckoutBundledPluginRoot(legacyRoot)) { - return false; - } - const relativePluginRoot = path.relative( - resolveComparablePath(legacyRoot, realpathCache), - resolveComparablePath(pluginRootDir, realpathCache), - ); - if (!isRelativePathInsideOrEqual(relativePluginRoot)) { - return false; - } - if (plugin.packageBuild?.bundledDist === false) { - return true; - } - if (sourcePluginOptsOutOfBundledDist(path.join(legacyRoot, relativePluginRoot))) { - // Older index records lack packageBuild. Re-derive once so runtime loading - // and OpenClaw fingerprint the same source-only artifact. - return false; - } - // Discovery prefers a built plugin whenever the same child exists in the - // packaged root. Keep source-only bundled plugins, but invalidate stale - // source records once their built peer appears. - return !fs.existsSync(path.join(bundledPluginsDir, relativePluginRoot)); -} - -function sourcePluginOptsOutOfBundledDist(pluginRootDir: string): boolean { - const packageJson = tryReadJsonSync(path.join(pluginRootDir, "package.json")); - return getPackageManifestMetadata(packageJson ?? undefined)?.build?.bundledDist === false; -} - -function isSourceCheckoutBundledPluginRoot(extensionsDir: string): boolean { - const packageRoot = path.dirname(extensionsDir); - return ( - fs.existsSync(extensionsDir) && - fs.existsSync(path.join(packageRoot, ".git")) && - fs.existsSync(path.join(packageRoot, "pnpm-workspace.yaml")) && - fs.existsSync(path.join(packageRoot, "src")) - ); -} - -function hashExistingFile(filePath: string): string | null { - try { - return crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex"); - } catch { - return null; - } -} - -function resolveRecordPackageJsonPath( - plugin: InstalledPluginIndexRecord, - realpathCache: Map, -): string | null { - const packageJsonPath = plugin.packageJson?.path; - if (!packageJsonPath) { - return null; - } - const rootDir = plugin.rootDir || path.dirname(plugin.manifestPath); - const resolved = path.resolve(rootDir, packageJsonPath); - const relative = path.relative(rootDir, resolved); - if (!isRelativePathInsideOrEqual(relative)) { - return null; - } - const realRelative = path.relative( - resolveComparablePath(rootDir, realpathCache), - resolveComparablePath(resolved, realpathCache), - ); - return isRelativePathInsideOrEqual(realRelative) ? resolved : null; -} - -function hasStalePersistedPluginDiagnostics(index: InstalledPluginIndex): boolean { - return index.diagnostics.some((diag) => { - const source = diag.source; - return ( - typeof diag.pluginId === "string" && - diag.pluginId.trim().length > 0 && - typeof source === "string" && - path.isAbsolute(source) && - !fs.existsSync(source) - ); - }); -} - -function hasStalePersistedPluginMetadata( - index: InstalledPluginIndex, - realpathCache: Map, -): boolean { - return index.plugins.some((plugin) => { - if (!hasOptionalMissingPluginManifestFile(plugin)) { - const manifestSignatureMatches = fileSignatureMatches( - plugin.manifestPath, - plugin.manifestFile, + if (!plugin.enabled && !fs.existsSync(plugin.rootDir)) { + const allowedRoots = [bundledRoot, ...overlays, ...(legacyRoot ? [legacyRoot] : [])]; + return !allowedRoots.some((root) => + isContainedPluginPath(root, plugin.rootDir, realpathCache), ); - if (manifestSignatureMatches !== true) { - const manifestHash = hashExistingFile(plugin.manifestPath); - if (manifestHash && manifestHash !== plugin.manifestHash) { - return true; - } + } + if (isRealPathInside(bundledRoot, plugin.rootDir, realpathCache)) { + if (!sourceCheckout) { + return false; } + const resolvedBundledRoot = safeRealpathSync(bundledRoot, realpathCache) ?? bundledRoot; + const resolvedPluginRoot = safeRealpathSync(plugin.rootDir, realpathCache) ?? plugin.rootDir; + const sourcePackage = tryReadJsonSync( + path.join( + legacyRoot, + path.relative(resolvedBundledRoot, resolvedPluginRoot), + "package.json", + ), + ); + return getPackageManifestMetadata(sourcePackage ?? undefined)?.build?.bundledDist === false; } - const packageJsonPath = resolveRecordPackageJsonPath(plugin, realpathCache); - if (!plugin.packageJson?.hash) { - return false; - } - if (!packageJsonPath) { - return true; - } - const packageJsonSignatureMatches = fileSignatureMatches( - packageJsonPath, - plugin.packageJson.fileSignature, + return ( + !overlays.some((root) => isRealPathInside(root, plugin.rootDir, realpathCache)) && + !( + plugin.packageBuild?.bundledDist === false && + legacyRoot && + isRealPathInside(legacyRoot, plugin.rootDir, realpathCache) + ) ); - if (packageJsonSignatureMatches === true && plugin.origin === "bundled") { - return false; - } - if (packageJsonSignatureMatches === false) { - return hashExistingFile(packageJsonPath) !== plugin.packageJson.hash; - } - // Fast same-size rewrites can preserve observable stat fields on some filesystems. - const packageJsonHash = hashExistingFile(packageJsonPath); - return packageJsonHash !== plugin.packageJson.hash; }); } -function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv) { - return loadInstalledPluginIndexInstallRecordsSync({ +function hasRecoveredInstallRecordsMissingFromPersistedIndex( + index: InstalledPluginIndex, + params: LoadPluginRegistryParams, + env: NodeJS.ProcessEnv, +): boolean { + const installRecords = loadInstalledPluginIndexInstallRecordsSync({ env, ...(params.stateDir ? { stateDir: params.stateDir } : {}), ...(params.filePath @@ -454,28 +341,32 @@ function loadSnapshotInstallRecords(params: LoadPluginRegistryParams, env: NodeJ ? { filePath: params.pluginIndexFilePath } : {}), }); + const pluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId)); + return Object.keys(installRecords).some( + (pluginId) => !index.installRecords?.[pluginId] || !pluginIds.has(pluginId), + ); } -function hasRecoveredInstallRecordsMissingFromPersistedIndex( +function requiresDerivedRegistryValidation( index: InstalledPluginIndex, - installRecords: ReturnType, + params: LoadPluginRegistryParams, env: NodeJS.ProcessEnv, + hasStalePluginFiles: () => boolean, ): boolean { - const persistedRecords = extractPluginInstallRecordsFromInstalledPluginIndex(index); - const persistedPluginIds = new Set(index.plugins.map((plugin) => plugin.pluginId)); - return Object.entries(installRecords).some(([pluginId, record]) => { - if (persistedRecords[pluginId] && persistedPluginIds.has(pluginId)) { - return false; - } - const installPaths = [record.installPath, record.sourcePath].filter( - (candidate): candidate is string => - typeof candidate === "string" && candidate.trim().length > 0, - ); - if (installPaths.length === 0) { - return true; - } - return installPaths.some((installPath) => fs.existsSync(resolveUserPath(installPath, env))); - }); + return ( + params.candidates !== undefined || + params.discovery !== undefined || + params.diagnostics !== undefined || + params.installRecords !== undefined || + normalizePluginsConfig(params.config?.plugins).loadPaths.length > 0 || + hasMissingConfigPathActivationMetadata(index) || + index.diagnostics.some(({ pluginId, source }) => + Boolean(pluginId && source && path.isAbsolute(source) && !fs.existsSync(source)), + ) || + hasMismatchedPersistedBundledRoot(index, env) || + hasStalePluginFiles() || + hasRecoveredInstallRecordsMissingFromPersistedIndex(index, params, env) + ); } export function loadPluginRegistrySnapshotWithMetadata( @@ -494,96 +385,117 @@ export function loadPluginRegistrySnapshotWithMetadata( } const env = params.env ?? process.env; - const memoKey = resolvePluginRegistrySnapshotMemoKey(params, env); - const memo = findPluginRegistrySnapshotMemo(memoKey); - if (memo) { - return memo; - } - // Bound canonical paths to this registry build; lifecycle changes must - // never reuse security-sensitive symlink or plugin-root resolutions. - const realpathCache = new Map(); - const diagnostics: PluginRegistrySnapshotDiagnostic[] = []; const persistedReadsEnabled = params.preferPersisted !== false; - const pushStaleSourceDiagnostic = (message: string): void => { - diagnostics.push({ level: "warn", code: "persisted-registry-stale-source", message }); - }; - if (persistedReadsEnabled) { - const persistedIndex = readPersistedInstalledPluginIndexSync(params); - if (persistedIndex) { - if ( - params.config && - persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config) - ) { - diagnostics.push({ - level: "warn", - code: "persisted-registry-stale-policy", - message: - "Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - }); - } else if (hasMissingPersistedPluginSource(persistedIndex)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry points at missing plugin files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if (hasMismatchedPersistedBundledPluginRoot(persistedIndex, env, realpathCache)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry points at a different bundled plugin tree; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if ( - hasMismatchedPersistedConfigPathPlugins(persistedIndex, params, env, realpathCache) - ) { - pushStaleSourceDiagnostic( - "Persisted plugin registry does not match configured load-path plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if (hasStalePersistedPluginDiagnostics(persistedIndex)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry contains diagnostics referencing missing paths; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if (hasMissingConfigPathActivationMetadata(persistedIndex)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry is missing config-path startup metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if (hasStalePersistedPluginMetadata(persistedIndex, realpathCache)) { - pushStaleSourceDiagnostic( - "Persisted plugin registry metadata no longer matches plugin manifest or package files; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else if ( - hasRecoveredInstallRecordsMissingFromPersistedIndex( - persistedIndex, - loadSnapshotInstallRecords(params, env), - env, - ) - ) { - pushStaleSourceDiagnostic( - "Persisted plugin registry is missing recoverable managed npm plugins; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", - ); - } else { - const persistedResult: PluginRegistrySnapshotResult = { - snapshot: persistedIndex, - source: "persisted", - diagnostics, - }; - return rememberPluginRegistrySnapshotMemo(memoKey, persistedResult); - } - } else { - diagnostics.push({ - level: "info", - code: "persisted-registry-missing", - message: "Persisted plugin registry is missing or invalid; using derived plugin index.", - }); - } + if (!persistedReadsEnabled) { + const derived = loadInstalledPluginIndexWithDiscovery({ + ...params, + installRecords: params.installRecords ?? {}, + }); + return { + snapshot: derived.index, + source: "derived", + diagnostics: [], + discovery: derived.discovery, + manifestRegistry: derived.manifestRegistry, + }; + } + + const diagnostics: PluginRegistrySnapshotDiagnostic[] = []; + const persistedIndex = readPersistedInstalledPluginIndexSync(params); + let stalePluginFiles: boolean | undefined; + const hasStalePluginFiles = () => + (stalePluginFiles ??= persistedIndex ? hasStalePersistedPluginFiles(persistedIndex) : false); + if (!persistedIndex) { + diagnostics.push({ + level: "info", + code: "persisted-registry-missing", + message: "Persisted plugin registry is missing or invalid; using derived plugin index.", + }); + } else if ( + params.config && + persistedIndex.policyHash !== resolveInstalledPluginIndexPolicyHash(params.config) + ) { + diagnostics.push({ + level: "warn", + code: "persisted-registry-stale-policy", + message: + "Persisted plugin registry policy does not match current config; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", + }); + } else if (!requiresDerivedRegistryValidation(persistedIndex, params, env, hasStalePluginFiles)) { + return { + snapshot: persistedIndex, + source: "persisted", + diagnostics, + }; } const derived = loadInstalledPluginIndexWithDiscovery({ ...params, - installRecords: persistedReadsEnabled ? params.installRecords : (params.installRecords ?? {}), + ...(params.filePath && !params.pluginIndexFilePath + ? { pluginIndexFilePath: params.filePath } + : {}), }); - return rememberPluginRegistrySnapshotMemo(memoKey, { + const comparePackageJsonPath = + params.candidates !== undefined || params.discovery !== undefined || hasStalePluginFiles(); + const excludedMissingDisabledPlugins = new Map(); + if ( + persistedIndex && + params.candidates === undefined && + params.discovery === undefined && + params.installRecords === undefined && + !hasStalePluginFiles() && + !hasMismatchedPersistedBundledRoot(persistedIndex, env) + ) { + const derivedPluginIds = new Set(derived.index.plugins.map((plugin) => plugin.pluginId)); + for (const plugin of persistedIndex.plugins) { + if (!plugin.enabled && !derivedPluginIds.has(plugin.pluginId)) { + excludedMissingDisabledPlugins.set(plugin.pluginId, plugin.rootDir); + } + } + } + const contentMatches = + persistedIndex && + diagnostics.length === 0 && + isDeepStrictEqual( + resolvePluginRegistryContent( + persistedIndex, + comparePackageJsonPath, + excludedMissingDisabledPlugins, + ), + resolvePluginRegistryContent( + derived.index, + comparePackageJsonPath, + excludedMissingDisabledPlugins, + ), + ); + if (persistedIndex && contentMatches) { + const packageMetadataMatches = isDeepStrictEqual( + resolvePluginRegistryContent(persistedIndex, true), + resolvePluginRegistryContent(derived.index, true), + ); + return { + snapshot: persistedIndex, + source: "persisted", + diagnostics, + discovery: derived.discovery, + ...(packageMetadataMatches ? { manifestRegistry: derived.manifestRegistry } : {}), + }; + } else if (persistedIndex && diagnostics.length === 0) { + diagnostics.push({ + level: "warn", + code: "persisted-registry-stale-source", + message: + "Persisted plugin registry no longer matches current plugin discovery or metadata; using derived plugin index. Run `openclaw plugins registry --refresh` to update the persisted registry.", + }); + } + + return { snapshot: derived.index, source: "derived", diagnostics, discovery: derived.discovery, manifestRegistry: derived.manifestRegistry, - }); + }; } function resolveSnapshot(params: LoadPluginRegistryParams = {}): PluginRegistrySnapshot { @@ -595,6 +507,7 @@ export function loadPluginRegistrySnapshot( ): PluginRegistrySnapshot { return resolveSnapshot(params); } + export function getPluginRecord(params: GetPluginRecordParams): PluginRegistryRecord | undefined { return getInstalledPluginRecord(resolveSnapshot(params), params.pluginId); } diff --git a/src/plugins/plugin-registry.test.ts b/src/plugins/plugin-registry.test.ts index 5dc51b2f8bc1..7bfd7dcdcd66 100644 --- a/src/plugins/plugin-registry.test.ts +++ b/src/plugins/plugin-registry.test.ts @@ -4,10 +4,7 @@ import fs from "node:fs"; import path from "node:path"; import { expectDefined } from "@openclaw/normalization-core"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { - closeOpenClawStateDatabaseForTest, - runOpenClawStateWriteTransaction, -} from "../state/openclaw-state-db.js"; +import { closeOpenClawStateDatabaseForTest } from "../state/openclaw-state-db.js"; import type { PluginCandidate } from "./discovery.js"; import { readPersistedInstalledPluginIndex, @@ -169,15 +166,6 @@ function createIndex( }; } -function createPersistableIndex(pluginId: string): InstalledPluginIndex { - const index = createIndex(pluginId); - const plugins = index.plugins.map((plugin) => Object.assign({}, plugin, { enabled: false })); - return { - ...index, - plugins, - }; -} - function requireRecord(value: unknown, label: string): Record { if (!value || typeof value !== "object") { throw new Error(`expected ${label}`); @@ -330,6 +318,29 @@ describe("plugin registry facade", () => { ).toEqual(["demo"]); }); + it("keeps missing disabled records inspectable from the persisted registry", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const config = { plugins: { entries: { demo: { enabled: false } } } }; + const env = hermeticEnv(); + const persisted = loadPluginRegistrySnapshot({ + candidates: [createCandidate(rootDir)], + config, + env, + preferPersisted: false, + }); + await writePersistedInstalledPluginIndex(persisted, { stateDir }); + fs.rmSync(rootDir, { recursive: true }); + + const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, config, env }); + + expect(result.source).toBe("persisted"); + expectPluginRecordFields(getPluginRecord({ index: result.snapshot, pluginId: "demo" }), { + pluginId: "demo", + enabled: false, + }); + }); + it("resolves contribution owners from a plugin lookup table without rereading manifests", () => { const rootDir = makeTempDir(); const candidate = createCandidate(rootDir); @@ -471,7 +482,7 @@ describe("plugin registry facade", () => { expect(normalizedConfig.allow).toEqual(["demo"]); }); - it("reads the persisted registry before deriving from discovered candidates", async () => { + it("treats explicit discovered candidates as authoritative", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); const persistedRootDir = makeTempDir(); @@ -509,13 +520,70 @@ describe("plugin registry facade", () => { env: hermeticEnv(), }); - expect(result.source).toBe("persisted"); - expect(result.diagnostics).toStrictEqual([]); + expect(result.source).toBe("derived"); + expectDiagnosticCodes(result.diagnostics, ["persisted-registry-stale-source"]); expect(listPluginRecords({ index: result.snapshot }).map((plugin) => plugin.pluginId)).toEqual([ - "persisted", + "demo", ]); }); + it("keeps content-equivalent timestamp changes on the persisted path", async () => { + const stateDir = makeTempDir(); + const rootDir = makeTempDir(); + const env = hermeticEnv(); + const persisted = loadPluginRegistrySnapshot({ + candidates: [createCandidate(rootDir)], + env, + preferPersisted: false, + }); + await writePersistedInstalledPluginIndex( + { + ...persisted, + plugins: [ + { + ...expectDefined(persisted.plugins[0], "persisted plugin test invariant"), + syntheticAuthRefs: ["demo"], + }, + ...persisted.plugins.slice(1), + ], + }, + { stateDir }, + ); + const manifestPath = path.join(rootDir, "openclaw.plugin.json"); + const future = new Date(Date.now() + 1_000); + fs.utimesSync(manifestPath, future, future); + + const result = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); + + expect(result.source).toBe("persisted"); + expect(result.snapshot.plugins[0]?.syntheticAuthRefs).toEqual(["demo"]); + }); + + it("reads install records from a custom SQLite registry path", async () => { + const tempDir = makeTempDir(); + const rootDir = makeTempDir(); + const filePath = path.join(tempDir, "custom-registry.sqlite"); + const env = hermeticEnv(); + const persisted = loadPluginRegistrySnapshot({ + candidates: [createCandidate(rootDir)], + env, + preferPersisted: false, + }); + persisted.installRecords = { + demo: { source: "npm", spec: "demo@1.0.0", installPath: rootDir }, + }; + await writePersistedInstalledPluginIndex(persisted, { filePath }); + + const result = loadPluginRegistrySnapshotWithMetadata({ filePath, env }); + + expect(result.source).toBe("persisted"); + expectInstallRecord(result.snapshot.installRecords, "demo", { + source: "npm", + spec: "demo@1.0.0", + installPath: rootDir, + }); + }); + it("falls back to the derived registry when persisted source paths are missing", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); @@ -819,7 +887,7 @@ describe("plugin registry facade", () => { expectSnapshotPluginIds(result.snapshot, ["demo"]); }); - it("reuses config-scoped derived registries within the process", () => { + it("derives config-scoped registries for cold callers", () => { const stateDir = makeTempDir(); const workspaceDir = makeTempDir(); const bundledRoot = makeTempDir(); @@ -853,7 +921,7 @@ describe("plugin registry facade", () => { expect(first.source).toBe("derived"); expect(second.source).toBe("derived"); expect(manifestReadsAfterFirst).toBeGreaterThan(0); - expect(manifestReadsAfterSecond).toBe(manifestReadsAfterFirst); + expect(manifestReadsAfterSecond).toBeGreaterThan(manifestReadsAfterFirst); }); it("reloads profile extensions after the metadata lifecycle is cleared", () => { @@ -881,7 +949,7 @@ describe("plugin registry facade", () => { expectSnapshotPluginIds(second.snapshot, ["first", "second"]); }); - it("keys the process registry memo by resolved host contract version", () => { + it("derives the resolved host contract version", () => { const stateDir = makeTempDir(); const bundledRoot = makeTempDir(); const rootDir = path.join(bundledRoot, "demo"); @@ -907,56 +975,6 @@ describe("plugin registry facade", () => { expect(second.snapshot.hostContractVersion).toBe("2026.4.26"); }); - it("clears the process registry memo after persisted registry writes", async () => { - const stateDir = makeTempDir(); - const env = hermeticEnv(); - await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir }); - - const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - await writePersistedInstalledPluginIndex(createPersistableIndex("second"), { stateDir }); - const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - - expect(first.source).toBe("persisted"); - expect(second.source).toBe("persisted"); - expectSnapshotPluginIds(first.snapshot, ["first"]); - expectSnapshotPluginIds(second.snapshot, ["second"]); - }); - - it("reloads externally changed persisted state after the metadata lifecycle is cleared", async () => { - const stateDir = makeTempDir(); - const env = hermeticEnv(); - await writePersistedInstalledPluginIndex(createPersistableIndex("first"), { stateDir }); - const first = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - const external = createPersistableIndex("second-external"); - runOpenClawStateWriteTransaction( - ({ db }) => { - db.prepare( - ` - UPDATE installed_plugin_index - SET plugins_json = ?, - install_records_json = ?, - diagnostics_json = ?, - updated_at_ms = ? - WHERE index_key = 'installed-plugin-index' - `, - ).run( - JSON.stringify(external.plugins), - JSON.stringify(external.installRecords), - JSON.stringify(external.diagnostics), - Date.now(), - ); - }, - { env: { ...env, OPENCLAW_STATE_DIR: stateDir } }, - ); - clearPluginMetadataLifecycleCaches(); - const second = loadPluginRegistrySnapshotWithMetadata({ stateDir, env }); - - expect(first.source).toBe("persisted"); - expect(second.source).toBe("persisted"); - expectSnapshotPluginIds(first.snapshot, ["first"]); - expectSnapshotPluginIds(second.snapshot, ["second-external"]); - }); - it("derives a fresh registry without persisted install records when caller disables persisted reads", async () => { const stateDir = makeTempDir(); const rootDir = makeTempDir(); diff --git a/src/plugins/providers.runtime.consult-current-snapshot.test.ts b/src/plugins/providers.runtime.consult-current-snapshot.test.ts index da2fa927c1f0..b08058ccfe5c 100644 --- a/src/plugins/providers.runtime.consult-current-snapshot.test.ts +++ b/src/plugins/providers.runtime.consult-current-snapshot.test.ts @@ -16,8 +16,8 @@ import { resetPluginRuntimeStateForTest } from "./runtime.js"; const loadPluginRegistrySnapshotWithMetadata = vi.hoisted(() => vi.fn()); const loadPluginManifestRegistryForInstalledIndex = vi.hoisted(() => vi.fn()); -vi.mock("./plugin-registry.js", async (importOriginal) => { - const actual = await importOriginal(); +vi.mock("./plugin-registry-snapshot.js", async (importOriginal) => { + const actual = await importOriginal(); return { ...actual, loadPluginRegistrySnapshotWithMetadata: (params: unknown) => diff --git a/src/plugins/web-fetch-providers.runtime.test.ts b/src/plugins/web-fetch-providers.runtime.test.ts index 5b010654b66d..9ded1713f780 100644 --- a/src/plugins/web-fetch-providers.runtime.test.ts +++ b/src/plugins/web-fetch-providers.runtime.test.ts @@ -104,9 +104,10 @@ function createRuntimeWebFetchProvider() { describe("resolvePluginWebFetchProviders", () => { beforeAll(async () => { - vi.doMock("./plugin-registry.js", async () => { - const actual = - await vi.importActual("./plugin-registry.js"); + vi.doMock("./plugin-registry-snapshot.js", async () => { + const actual = await vi.importActual( + "./plugin-registry-snapshot.js", + ); return { ...actual, loadPluginRegistrySnapshotWithMetadata: () => ({ diff --git a/src/proxy-capture/store.sqlite.test.ts b/src/proxy-capture/store.sqlite.test.ts index 719b2a5a3a94..486ea164e548 100644 --- a/src/proxy-capture/store.sqlite.test.ts +++ b/src/proxy-capture/store.sqlite.test.ts @@ -1,6 +1,7 @@ // Proxy capture SQLite store tests cover persisted capture reads and writes. import fs from "node:fs"; import path from "node:path"; +import { constants } from "node:sqlite"; import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanupTempDirs, makeTempDir } from "../../test/helpers/temp-dir.js"; import { resolveSqliteDatabaseFilePaths } from "../infra/sqlite-files.js"; @@ -161,6 +162,66 @@ describe("DebugProxyCaptureStore", () => { expect(lease.store.isClosed).toBe(true); }); + it.each(["deleteSessions", "purgeAll"] as const)( + "rolls back path-based %s when session deletion fails", + (operation) => { + const root = makeTempDir(cleanupDirs, "openclaw-proxy-capture-rollback-"); + const dbPath = path.join(root, "capture.sqlite"); + const blobDir = path.join(root, "blobs"); + const lease = acquireDebugProxyCaptureStore(dbPath, blobDir); + const sessionId = "path-based-rollback-session"; + + try { + lease.store.upsertSession({ + id: sessionId, + startedAt: 1, + mode: "sdk", + sourceScope: "openclaw", + sourceProcess: "plugin", + dbPath, + blobDir, + }); + const blob = lease.store.persistPayload(Buffer.from("rollback payload"), "text/plain"); + lease.store.recordEvent({ + sessionId, + ts: 2, + sourceScope: "openclaw", + sourceProcess: "plugin", + protocol: "https", + direction: "outbound", + kind: "request", + flowId: "path-based-rollback-flow", + dataBlobId: blob.blobId, + dataSha256: blob.sha256, + }); + + const cleanup = () => + operation === "deleteSessions" + ? lease.store.deleteSessions([sessionId]) + : lease.store.purgeAll(); + lease.store.db.setAuthorizer((action, table) => + action === constants.SQLITE_DELETE && table === "capture_sessions" + ? constants.SQLITE_DENY + : constants.SQLITE_OK, + ); + + expect(cleanup).toThrow(/not authorized/u); + lease.store.db.setAuthorizer(null); + expect(lease.store.listSessions()).toHaveLength(1); + expect(lease.store.getSessionEvents(sessionId)).toHaveLength(1); + expect(fs.existsSync(blob.path)).toBe(true); + + expect(cleanup()).toEqual({ sessions: 1, events: 1, blobs: 1 }); + expect(lease.store.listSessions()).toEqual([]); + expect(lease.store.getSessionEvents(sessionId)).toEqual([]); + expect(fs.existsSync(blob.path)).toBe(false); + } finally { + lease.store.db.setAuthorizer(null); + lease.release(); + } + }, + ); + it("uses rollback journaling for captures on NFS-backed volumes", () => { vi.spyOn(fs, "statfsSync").mockReturnValue({ type: 0x6969, diff --git a/src/proxy-capture/store.sqlite.ts b/src/proxy-capture/store.sqlite.ts index 074926061a5e..4f5ecbce5e1c 100644 --- a/src/proxy-capture/store.sqlite.ts +++ b/src/proxy-capture/store.sqlite.ts @@ -616,7 +616,9 @@ class DebugProxyCaptureStoreImpl { const eventCount = (this.db.prepare(`SELECT COUNT(*) AS count FROM capture_events`).get() as { count: number }) .count ?? 0; - this.db.exec(`DELETE FROM capture_events; DELETE FROM capture_sessions;`); + runSqliteImmediateTransactionSync(this.db, () => { + this.db.exec(`DELETE FROM capture_events; DELETE FROM capture_sessions;`); + }); let blobs = 0; if (fs.existsSync(this.pathBased.blobDir)) { for (const entry of fs.readdirSync(this.pathBased.blobDir)) { @@ -764,12 +766,14 @@ class DebugProxyCaptureStoreImpl { ) .get(...sessionIds) as { count: number } ).count ?? 0; - this.db - .prepare(`DELETE FROM capture_events WHERE session_id IN (${placeholders})`) - .run(...sessionIds); - this.db - .prepare(`DELETE FROM capture_sessions WHERE id IN (${placeholders})`) - .run(...sessionIds); + runSqliteImmediateTransactionSync(this.db, () => { + this.db + .prepare(`DELETE FROM capture_events WHERE session_id IN (${placeholders})`) + .run(...sessionIds); + this.db + .prepare(`DELETE FROM capture_sessions WHERE id IN (${placeholders})`) + .run(...sessionIds); + }); const candidateBlobIds = blobRows .map((row) => row.blobId?.trim()) .filter((blobId): blobId is string => Boolean(blobId)); diff --git a/src/runtime.test.ts b/src/runtime.test.ts index c5234160e4ef..89ca513267cb 100644 --- a/src/runtime.test.ts +++ b/src/runtime.test.ts @@ -1,5 +1,5 @@ // Tests for terminal runtime helpers. -import { describe, expect, it, vi } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; // Mock dependencies vi.mock("../packages/terminal-core/src/progress-line.js", () => ({ @@ -10,8 +10,11 @@ vi.mock("../packages/terminal-core/src/restore.js", () => ({ restoreTerminalState: vi.fn(), })); +import { restoreTerminalState } from "../packages/terminal-core/src/restore.js"; +import { loggingState } from "./logging/state.js"; import { createNonExitingRuntime, + defaultRuntime, ExitError, writeRuntimeJson, writeRuntimeStdout, @@ -119,3 +122,107 @@ describe("writeRuntimeStdout", () => { expect(runtime.log).toHaveBeenCalledWith("plain output"); }); }); + +describe("defaultRuntime terminal restoration", () => { + const originalForceConsoleToStderr = loggingState.forceConsoleToStderr; + const originalStdoutIsTTY = Object.getOwnPropertyDescriptor(process.stdout, "isTTY"); + const originalStderrIsTTY = Object.getOwnPropertyDescriptor(process.stderr, "isTTY"); + + afterEach(() => { + vi.restoreAllMocks(); + vi.mocked(restoreTerminalState).mockReset(); + loggingState.forceConsoleToStderr = originalForceConsoleToStderr; + if (originalStdoutIsTTY) { + Object.defineProperty(process.stdout, "isTTY", originalStdoutIsTTY); + } else { + Reflect.deleteProperty(process.stdout, "isTTY"); + } + if (originalStderrIsTTY) { + Object.defineProperty(process.stderr, "isTTY", originalStderrIsTTY); + } else { + Reflect.deleteProperty(process.stderr, "isTTY"); + } + }); + + it.each([0, 1])("keeps machine-readable stdout clean on exit %i", async (exitCode) => { + const actualRestore = await vi.importActual< + typeof import("../packages/terminal-core/src/restore.js") + >("../packages/terminal-core/src/restore.js"); + vi.mocked(restoreTerminalState).mockImplementation(actualRestore.restoreTerminalState); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + const stdout: string[] = []; + const stderr: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + vi.spyOn(process, "exit").mockImplementation(((code?: number) => { + throw new ExitError(code ?? 0); + }) as typeof process.exit); + loggingState.forceConsoleToStderr = true; + + defaultRuntime.writeJson({ ok: exitCode === 0 }); + expect(() => defaultRuntime.exit(exitCode)).toThrow(ExitError); + + expect(JSON.parse(stdout.join(""))).toEqual({ ok: exitCode === 0 }); + expect(stderr.join("")).toContain("\x1b[?25h"); + }); + + it("preserves stdout terminal restoration for human output", async () => { + const actualRestore = await vi.importActual< + typeof import("../packages/terminal-core/src/restore.js") + >("../packages/terminal-core/src/restore.js"); + vi.mocked(restoreTerminalState).mockImplementation(actualRestore.restoreTerminalState); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + const stdout: string[] = []; + const stderr: string[] = []; + vi.spyOn(process.stdout, "write").mockImplementation(((chunk: unknown) => { + stdout.push(String(chunk)); + return true; + }) as typeof process.stdout.write); + vi.spyOn(process.stderr, "write").mockImplementation(((chunk: unknown) => { + stderr.push(String(chunk)); + return true; + }) as typeof process.stderr.write); + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ExitError(1); + }) as typeof process.exit); + loggingState.forceConsoleToStderr = false; + + defaultRuntime.writeStdout("operator-visible output"); + expect(() => defaultRuntime.exit(1)).toThrow(ExitError); + + expect(stdout.join("")).toContain("operator-visible output\n"); + expect(stdout.join("")).toContain("\x1b[?25h"); + expect(stderr).toEqual([]); + }); + + it("honors an explicitly selected reset stream in machine-output mode", async () => { + const actualRestore = await vi.importActual< + typeof import("../packages/terminal-core/src/restore.js") + >("../packages/terminal-core/src/restore.js"); + vi.mocked(restoreTerminalState).mockImplementation(actualRestore.restoreTerminalState); + Object.defineProperty(process.stdout, "isTTY", { value: true, configurable: true }); + Object.defineProperty(process.stderr, "isTTY", { value: true, configurable: true }); + const stdout = vi.spyOn(process.stdout, "write").mockImplementation(() => true); + const stderr = vi.spyOn(process.stderr, "write").mockImplementation(() => true); + const resetWrite = vi.fn(() => true); + const resetStream = { isTTY: true, write: resetWrite } as unknown as NodeJS.WriteStream; + vi.spyOn(process, "exit").mockImplementation((() => { + throw new ExitError(1); + }) as typeof process.exit); + loggingState.forceConsoleToStderr = true; + + expect(() => defaultRuntime.exit(1, { resetStream })).toThrow(ExitError); + + expect(resetWrite).toHaveBeenCalledWith(expect.stringContaining("\x1b[?25h")); + expect(stdout).not.toHaveBeenCalled(); + expect(stderr).not.toHaveBeenCalled(); + }); +}); diff --git a/src/runtime.ts b/src/runtime.ts index 814be842b550..588a7ea92dc5 100644 --- a/src/runtime.ts +++ b/src/runtime.ts @@ -1,6 +1,7 @@ // Re-exports terminal runtime helpers used by CLI command implementations. import { clearActiveProgressLine } from "../packages/terminal-core/src/progress-line.js"; import { restoreTerminalState } from "../packages/terminal-core/src/restore.js"; +import { loggingState } from "./logging/state.js"; export type RuntimeExitOptions = { /** Route ANSI terminal-reset bytes away from structured stdout when needed. */ @@ -96,12 +97,25 @@ function createRuntimeIo(): Pick[1]> = {}, +): void { + const resetStream = + options.resetStream ?? (loggingState.forceConsoleToStderr ? process.stderr : undefined); + restoreTerminalState(reason, { + ...options, + ...(resetStream ? { resetStream } : {}), + }); +} + export const defaultRuntime: OutputRuntimeEnv = { ...createRuntimeIo(), exit: (code, opts) => { - restoreTerminalState("runtime exit", { + restoreRuntimeTerminalState("runtime exit", { resumeStdinIfPaused: false, - resetStream: opts?.resetStream, + ...(opts?.resetStream ? { resetStream: opts.resetStream } : {}), }); process.exit(code); throw new Error("unreachable"); // satisfies tests when mocked diff --git a/src/secrets/provider-integrations.test.ts b/src/secrets/provider-integrations.test.ts index 458020d3faf0..4d81aa736fe3 100644 --- a/src/secrets/provider-integrations.test.ts +++ b/src/secrets/provider-integrations.test.ts @@ -35,6 +35,22 @@ function writeSecureFile(file: string, contents: string): void { fs.chmodSync(file, 0o600); } +function writePluginManifest(rootDir: string, manifest: Record): void { + fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); + fs.writeFileSync( + path.join(rootDir, "openclaw.plugin.json"), + JSON.stringify({ + ...manifest, + configSchema: { + type: "object", + additionalProperties: false, + properties: {}, + }, + }), + "utf8", + ); +} + function createCandidate( rootDir: string, idHint: string, @@ -48,6 +64,16 @@ function createCandidate( }; } +function loadTestRegistry( + rootDir: string, + idHint: string, + origin: PluginOrigin = "global", +): PluginManifestRegistry { + return loadPluginManifestRegistry({ + candidates: [createCandidate(rootDir, idHint, origin)], + }); +} + function pluginIntegrationProviderConfig(pluginId: string, integrationId: string) { return { source: "exec" as const, @@ -67,45 +93,33 @@ afterEach(() => { describe("secret provider integration presets", () => { it("materializes plugin manifest exec providers without provider-specific core code", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); makeSecureDir(path.join(rootDir, "bin")); writeSecureFile(path.join(rootDir, "bin", "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "acme-secrets", - name: "Acme Secrets", - secretProviderIntegrations: { - acme: { - providerAlias: "acme", - displayName: "Acme Vault", - description: "Acme exec resolver", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs", "--profile", "work"], - timeoutMs: 3000, - noOutputTimeoutMs: 3000, - maxOutputBytes: 4096, - passEnv: ["HOME"], - env: { - ACME_PROFILE: "work", - }, - jsonOnly: false, + writePluginManifest(rootDir, { + id: "acme-secrets", + name: "Acme Secrets", + secretProviderIntegrations: { + acme: { + providerAlias: "acme", + displayName: "Acme Vault", + description: "Acme exec resolver", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs", "--profile", "work"], + timeoutMs: 3000, + noOutputTimeoutMs: 3000, + maxOutputBytes: 4096, + passEnv: ["HOME"], + env: { + ACME_PROFILE: "work", }, + jsonOnly: false, }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "acme-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "acme-secrets"); + expect(registry.diagnostics).toEqual([]); expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { @@ -144,36 +158,24 @@ describe("secret provider integration presets", () => { it("normalizes manifest exec provider options to SecretRef provider schema limits", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "bounded-secrets", - secretProviderIntegrations: { - bounded: { - source: "exec", - command: "${node}", - args: ["./resolve.mjs", "ok", "x".repeat(1025)], - timeoutMs: 120001, - noOutputTimeoutMs: 1.5, - maxOutputBytes: 20 * 1024 * 1024 + 1, - passEnv: ["GOOD_ENV", "bad-env"], - }, + writePluginManifest(rootDir, { + id: "bounded-secrets", + secretProviderIntegrations: { + bounded: { + source: "exec", + command: "${node}", + args: ["./resolve.mjs", "ok", "x".repeat(1025)], + timeoutMs: 120001, + noOutputTimeoutMs: 1.5, + maxOutputBytes: 20 * 1024 * 1024 + 1, + passEnv: ["GOOD_ENV", "bad-env"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "bounded-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "bounded-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { id: "bounded", @@ -203,31 +205,19 @@ describe("secret provider integration presets", () => { it("skips presets whose provider alias cannot be used as a SecretRef provider", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "bad-secrets", - secretProviderIntegrations: { - bad: { - providerAlias: "../bad", - source: "exec", - command: "${node}", - }, + writePluginManifest(rootDir, { + id: "bad-secrets", + secretProviderIntegrations: { + bad: { + providerAlias: "../bad", + source: "exec", + command: "${node}", }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "bad-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "bad-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }); @@ -236,54 +226,34 @@ describe("secret provider integration presets", () => { const longPluginRootDir = makeTempDir(); const longPluginId = `plugin-${"x".repeat(129)}`; const longIntegrationId = `integration-${"x".repeat(129)}`; - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.writeFileSync(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n", "utf8"); - fs.writeFileSync(path.join(longPluginRootDir, "index.ts"), "export default {};\n", "utf8"); fs.writeFileSync( path.join(longPluginRootDir, "resolve.mjs"), "process.stdin.resume();\n", "utf8", ); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "long-integration-secrets", - secretProviderIntegrations: { - [longIntegrationId]: { - providerAlias: "short-alias", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "long-integration-secrets", + secretProviderIntegrations: { + [longIntegrationId]: { + providerAlias: "short-alias", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, + }, + }); + writePluginManifest(longPluginRootDir, { + id: longPluginId, + secretProviderIntegrations: { + vault: { + providerAlias: "short-plugin-alias", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - }), - "utf8", - ); - fs.writeFileSync( - path.join(longPluginRootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: longPluginId, - secretProviderIntegrations: { - vault: { - providerAlias: "short-plugin-alias", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, - }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const registry = loadPluginManifestRegistry({ candidates: [ @@ -299,61 +269,39 @@ describe("secret provider integration presets", () => { "skips non-node manifest preset commands for %s plugin roots", (origin) => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.mkdirSync(path.join(rootDir, "bin")); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: `${origin}-secrets`, - ...(origin === "bundled" ? { enabledByDefault: true } : {}), - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "./bin/vault-resolver", - }, + writePluginManifest(rootDir, { + id: `${origin}-secrets`, + ...(origin === "bundled" ? { enabledByDefault: true } : {}), + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "./bin/vault-resolver", }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)], + }, }); + const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }, ); it("skips presets from disabled installed plugins", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "disabled-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "disabled-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const registry = loadPluginManifestRegistry({ candidates: [createCandidate(rootDir, "disabled-secrets", "global")], @@ -386,28 +334,18 @@ describe("secret provider integration presets", () => { it("applies plugin id aliases when filtering disabled presets", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "openai", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "openai", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const config = { plugins: { entries: { @@ -429,32 +367,20 @@ describe("secret provider integration presets", () => { it("exposes bundled presets enabled by platform default", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "platform-secrets", - enabledByDefaultOnPlatforms: [process.platform], - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "platform-secrets", + enabledByDefaultOnPlatforms: [process.platform], + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "platform-secrets", "bundled")], + }, }); + const registry = loadTestRegistry(rootDir, "platform-secrets", "bundled"); expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { @@ -473,33 +399,21 @@ describe("secret provider integration presets", () => { const rootDir = makeTempDir(); const linkParent = makeTempDir(); const linkRoot = path.join(linkParent, "plugin-link"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); writeSecureFile(path.join(rootDir, "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "linked-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "linked-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); fs.symlinkSync(rootDir, linkRoot); - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(linkRoot, "linked-secrets", "global")], - }); + const registry = loadTestRegistry(linkRoot, "linked-secrets", "global"); expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { @@ -517,32 +431,20 @@ describe("secret provider integration presets", () => { "skips secret provider presets from %s plugin roots", (origin) => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: `${origin}-secrets`, - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: `${origin}-secrets`, + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, `${origin}-secrets`, origin)], + }, }); + const registry = loadTestRegistry(rootDir, `${origin}-secrets`, origin); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }, ); @@ -550,7 +452,6 @@ describe("secret provider integration presets", () => { it("resolves a node-based plugin preset with plugin trusted dirs", async () => { const rootDir = makeTempDir(); const resolverPath = path.join(rootDir, "bin", "resolve.mjs"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); makeSecureDir(path.dirname(resolverPath)); writeSecureFile( resolverPath, @@ -565,32 +466,21 @@ describe("secret provider integration presets", () => { "});", ].join("\n"), ); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "vault-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - allowInsecurePath: true, - }, + writePluginManifest(rootDir, { + id: "vault-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], + allowInsecurePath: true, }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); await withSecureTestNodeExecPath(async () => { - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "vault-secrets", "global")], - }); + const registry = loadTestRegistry(rootDir, "vault-secrets", "global"); const [preset] = listSecretProviderIntegrationPresets({ manifestRegistry: registry }); if (!preset) { throw new Error("Expected vault preset"); @@ -624,28 +514,18 @@ describe("secret provider integration presets", () => { it("fails closed when a plugin-managed provider is disabled", async () => { const rootDir = makeTempDir(); const resolverPath = path.join(rootDir, "resolve.mjs"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.writeFileSync(resolverPath, "process.stdin.resume();\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "revoked-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "revoked-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); + }, + }); const config = { plugins: { entries: { @@ -715,31 +595,19 @@ describe("secret provider integration presets", () => { it("skips node presets without a plugin-root relative entrypoint arg", () => { const rootDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "bad-trust-secrets", - secretProviderIntegrations: { - bad: { - source: "exec", - command: "${node}", - args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "bad-trust-secrets", + secretProviderIntegrations: { + bad: { + source: "exec", + command: "${node}", + args: ["--import", "./bin/hook.mjs", "./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "bad-trust-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "bad-trust-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }); @@ -748,38 +616,26 @@ describe("secret provider integration presets", () => { () => { const rootDir = makeTempDir(); const outsideDir = makeTempDir(); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.mkdirSync(path.join(rootDir, "bin")); fs.writeFileSync(path.join(outsideDir, "resolve.mjs"), "process.stdin.resume();\n"); fs.symlinkSync( path.join(outsideDir, "resolve.mjs"), path.join(rootDir, "bin", "resolve.mjs"), ); - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "symlink-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "symlink-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "symlink-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "symlink-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); }, ); @@ -790,34 +646,22 @@ describe("secret provider integration presets", () => { const linkedRoot = path.join(parentDir, "linked-plugin"); makeSecureDir(realRoot); fs.symlinkSync(realRoot, linkedRoot, "dir"); - fs.writeFileSync(path.join(realRoot, "index.ts"), "export default {};\n", "utf8"); makeSecureDir(path.join(realRoot, "bin")); writeSecureFile(path.join(realRoot, "bin", "resolve.mjs"), "process.stdin.resume();\n"); - fs.writeFileSync( - path.join(realRoot, "openclaw.plugin.json"), - JSON.stringify({ - id: "linked-root-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - }, + writePluginManifest(realRoot, { + id: "linked-root-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(linkedRoot, "linked-root-secrets")], + }, }); + const registry = loadTestRegistry(linkedRoot, "linked-root-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([ { id: "vault", @@ -845,36 +689,24 @@ describe("secret provider integration presets", () => { () => { const rootDir = makeTempDir(); const binDir = path.join(rootDir, "bin"); - fs.writeFileSync(path.join(rootDir, "index.ts"), "export default {};\n", "utf8"); fs.mkdirSync(binDir); fs.writeFileSync(path.join(binDir, "resolve.mjs"), "process.stdin.resume();\n"); fs.chmodSync(binDir, 0o777); try { - fs.writeFileSync( - path.join(rootDir, "openclaw.plugin.json"), - JSON.stringify({ - id: "writable-parent-secrets", - secretProviderIntegrations: { - vault: { - providerAlias: "vault", - source: "exec", - command: "${node}", - args: ["./bin/resolve.mjs"], - }, + writePluginManifest(rootDir, { + id: "writable-parent-secrets", + secretProviderIntegrations: { + vault: { + providerAlias: "vault", + source: "exec", + command: "${node}", + args: ["./bin/resolve.mjs"], }, - configSchema: { - type: "object", - additionalProperties: false, - properties: {}, - }, - }), - "utf8", - ); - - const registry = loadPluginManifestRegistry({ - candidates: [createCandidate(rootDir, "writable-parent-secrets")], + }, }); + const registry = loadTestRegistry(rootDir, "writable-parent-secrets"); + expect(listSecretProviderIntegrationPresets({ manifestRegistry: registry })).toEqual([]); } finally { fs.chmodSync(binDir, 0o700); diff --git a/src/secrets/runtime-state.test.ts b/src/secrets/runtime-state.test.ts index 1c132aea3179..54df4aec2e6a 100644 --- a/src/secrets/runtime-state.test.ts +++ b/src/secrets/runtime-state.test.ts @@ -39,6 +39,95 @@ import { type PreparedSecretsRuntimeSnapshot, } from "./runtime-state.js"; +type PreparedSnapshotOverrides = Omit< + Partial, + "authStoreCredentialsRevision" | "webTools" +>; + +function preparedSnapshot( + overrides: PreparedSnapshotOverrides = {}, +): PreparedSecretsRuntimeSnapshot { + return { + sourceConfig: {}, + config: {}, + authStores: [], + authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), + warnings: [], + webTools: { + search: { providerSource: "none", diagnostics: [] }, + fetch: { providerSource: "none", diagnostics: [] }, + diagnostics: [], + }, + ...overrides, + }; +} + +function preparedGatewayAuthSnapshot( + agentDir: string, + port: number, + store: PreparedSecretsRuntimeSnapshot["authStores"][number]["store"], +): PreparedSecretsRuntimeSnapshot { + return preparedSnapshot({ + config: { gateway: { port } }, + authStores: [{ agentDir, store }], + }); +} + +type ActivateOptions = Omit< + Parameters[0], + "snapshot" | "refreshContext" | "refreshHandler" +>; + +function activateSnapshot( + snapshot: PreparedSecretsRuntimeSnapshot, + options: ActivateOptions = {}, +): void { + activateSecretsRuntimeSnapshotState({ + snapshot, + refreshContext: null, + refreshHandler: null, + ...options, + }); +} + +type ActivateIfCurrentOptions = Omit< + Parameters[0], + "snapshot" | "expectedRevision" | "refreshContext" | "refreshHandler" +> & { expectedRevision?: number }; + +function activateSnapshotIfCurrent( + snapshot: PreparedSecretsRuntimeSnapshot, + options: ActivateIfCurrentOptions = {}, +): boolean { + return activateSecretsRuntimeSnapshotStateIfCurrent({ + snapshot, + expectedRevision: options.expectedRevision ?? getActiveSecretsRuntimeSnapshotRevision(), + refreshContext: null, + refreshHandler: null, + ...options, + }); +} + +type RestoreIfCurrentOptions = Omit< + Parameters[0], + "snapshot" | "ownedSnapshot" | "expectedRevision" | "refreshContext" | "refreshHandler" +> & { expectedRevision?: number }; + +function restoreSnapshotIfCurrent( + snapshot: PreparedSecretsRuntimeSnapshot, + ownedSnapshot: PreparedSecretsRuntimeSnapshot, + options: RestoreIfCurrentOptions = {}, +): boolean { + return restoreSecretsRuntimeSnapshotStateIfCurrent({ + snapshot, + ownedSnapshot, + expectedRevision: options.expectedRevision ?? getActiveSecretsRuntimeSnapshotRevision(), + refreshContext: null, + refreshHandler: null, + ...options, + }); +} + describe("secrets runtime state", () => { let envSnapshot: ReturnType; const autoCleanupTempDirs = useAutoCleanupTempDirTracker(afterEach); @@ -81,25 +170,14 @@ describe("secrets runtime state", () => { }); it("exposes the active config pair for hot paths without requiring the full snapshot", () => { - const snapshot: PreparedSecretsRuntimeSnapshot = { + const snapshot = preparedSnapshot({ sourceConfig: { agents: { list: [{ id: "source" }] } }, config: { agents: { list: [{ id: "runtime" }] } }, authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }; - - activateSecretsRuntimeSnapshotState({ - snapshot, - refreshContext: null, - refreshHandler: null, }); + activateSnapshot(snapshot); + const configSnapshot = getActiveSecretsRuntimeConfigSnapshot(); const fullSnapshot = getActiveSecretsRuntimeSnapshot(); @@ -115,23 +193,12 @@ describe("secrets runtime state", () => { provider: "default", id: "OPENCLAW_DEBUG_AUTH_TOKEN", }; - const snapshot: PreparedSecretsRuntimeSnapshot = { + const snapshot = preparedSnapshot({ sourceConfig: { gateway: { auth: { mode: "token", token: secretRef } } }, config: { gateway: { auth: { mode: "token", token: "resolved-debug-token" } } }, authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }; - activateSecretsRuntimeSnapshotState({ - snapshot, - refreshContext: null, - refreshHandler: null, }); + activateSnapshot(snapshot); const rawSourceConfig = { gateway: { port: 19_030 } } satisfies OpenClawConfig; const secretsSourceConfig = { ...rawSourceConfig, @@ -139,13 +206,12 @@ describe("secrets runtime state", () => { } satisfies OpenClawConfig; expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: { ...snapshot, sourceConfig: secretsSourceConfig }, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - runtimeSourceConfig: rawSourceConfig, - }), + activateSnapshotIfCurrent( + { ...snapshot, sourceConfig: secretsSourceConfig }, + { + runtimeSourceConfig: rawSourceConfig, + }, + ), ).toBe(true); expect(getRuntimeConfigSourceSnapshot()).toEqual(rawSourceConfig); @@ -156,22 +222,13 @@ describe("secrets runtime state", () => { it("rejects a source-only secrets write after runtime config ownership changes", () => { const initialConfig = { gateway: { port: 19_030 } } satisfies OpenClawConfig; const concurrentConfig = { gateway: { port: 19_031 } } satisfies OpenClawConfig; - activateSecretsRuntimeSnapshotState({ - snapshot: { + activateSnapshot( + preparedSnapshot({ sourceConfig: initialConfig, config: initialConfig, authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }, - refreshContext: null, - refreshHandler: null, - }); + }), + ); const staleMetadata = getRuntimeConfigSnapshotMetadata(); if (!staleMetadata) { throw new Error("expected runtime config metadata"); @@ -200,23 +257,14 @@ describe("secrets runtime state", () => { }, }, } satisfies OpenClawConfig; - activateSecretsRuntimeSnapshotState({ - snapshot: { + activateSnapshot( + preparedSnapshot({ sourceConfig: initialSource, config: runtimeConfig, authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }, - refreshContext: null, - refreshHandler: null, - runtimeSourceConfig: initialSource, - }); + }), + { runtimeSourceConfig: initialSource }, + ); const runtimeMetadata = getRuntimeConfigSnapshotMetadata(); if (!runtimeMetadata) { throw new Error("expected runtime config metadata"); @@ -234,11 +282,8 @@ describe("secrets runtime state", () => { const descendant = structuredClone(active); descendant.config.models!.providers!.openai!.baseUrl = "https://refreshed.example.invalid/v1"; expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: descendant, + activateSnapshotIfCurrent(descendant, { expectedRevision: committedRevision, - refreshContext: null, - refreshHandler: null, runtimeSourceConfig: nextSource, preserveActivationLineage: true, }), @@ -273,8 +318,7 @@ describe("secrets runtime state", () => { }, agentDir, ); - const snapshot: PreparedSecretsRuntimeSnapshot = { - sourceConfig: {}, + const snapshot = preparedSnapshot({ config: {}, authStores: [ { @@ -286,14 +330,7 @@ describe("secrets runtime state", () => { }, }, ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }; + }); setRuntimeAuthProfileStoreSnapshot( { version: 1, @@ -305,11 +342,7 @@ describe("secrets runtime state", () => { agentDir, ); - activateSecretsRuntimeSnapshotState({ - snapshot, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot); expect( getRuntimeAuthProfileStoreSnapshot(agentDir)?.usageStats?.["openai:default"], @@ -318,33 +351,14 @@ describe("secrets runtime state", () => { it("removes candidate-only auth profiles when rolling config back", () => { const agentDir = "/tmp/openclaw-auth-rollback-cas"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot(); const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); const candidate = snapshot("sk-old", 19_002); @@ -354,23 +368,10 @@ describe("secrets runtime state", () => { key: "sk-rejected-candidate", }; expect(previous).not.toBeNull(); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous!, - expectedRevision: candidateRevision, - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous!, candidate, { expectedRevision: candidateRevision }), ).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_001); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ @@ -392,18 +393,11 @@ describe("secrets runtime state", () => { profiles: AuthProfileStore["profiles"], port: number, state: Pick = {}, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [{ agentDir, store: { version: 1, profiles, ...state } }], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + ) => + preparedSnapshot({ + config: { gateway: { port } }, + authStores: [{ agentDir, store: { version: 1, profiles, ...state } }], + }); const predecessorProfiles = { "provider-a:default": profile("provider-a", "a-old"), "provider-b:default": profile("provider-b", "b-old"), @@ -413,11 +407,7 @@ describe("secrets runtime state", () => { lastGood: { provider: "provider-a:default" }, usageStats: { "provider-b:default": { lastUsed: 1 } }, }; - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(predecessorProfiles, 19_001, predecessorState), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot(predecessorProfiles, 19_001, predecessorState)); const previous = getActiveSecretsRuntimeSnapshot()!; const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); const activationProfiles = { @@ -450,14 +440,7 @@ describe("secrets runtime state", () => { 19_002, preparedState, ); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); const liveAfterActivation = getRuntimeAuthProfileStoreSnapshot(agentDir)!; liveAfterActivation.order = { provider: ["provider-q:login", "provider-b:default"] }; liveAfterActivation.lastGood = { provider: "provider-q:login" }; @@ -466,15 +449,7 @@ describe("secrets runtime state", () => { }; setRuntimeAuthProfileStoreSnapshot(liveAfterActivation, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles; expect(restored?.["provider-a:default"]).toMatchObject({ key: "a-old" }); expect(restored?.["provider-b:default"]).toMatchObject({ key: "b-external" }); @@ -492,33 +467,14 @@ describe("secrets runtime state", () => { it("preserves an auth rotation captured by the candidate", () => { const finalKey = "sk-candidate"; const agentDir = "/tmp/openclaw-auth-rollback-sk-candidate"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); setRuntimeAuthProfileStoreSnapshot( @@ -531,23 +487,8 @@ describe("secrets runtime state", () => { provider: "anthropic", key: "sk-rejected-candidate", }; - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_001); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: finalKey, @@ -607,50 +548,19 @@ describe("secrets runtime state", () => { provider, key, }); - const snapshot = ( - aKey: string | null, - bKey: string, - port: number, - aExternal = false, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - ...(aKey === null ? {} : { "provider-a:default": profile("provider-a", aKey) }), - "provider-b:default": profile("provider-b", bKey), - }, - runtimeExternalProfileIds: aExternal ? ["provider-a:default"] : undefined, - }, + const snapshot = (aKey: string | null, bKey: string, port: number, aExternal = false) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + ...(aKey === null ? {} : { "provider-a:default": profile("provider-a", aKey) }), + "provider-b:default": profile("provider-b", bKey), }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(baselineAKey, "b-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + runtimeExternalProfileIds: aExternal ? ["provider-a:default"] : undefined, + }); + activateSnapshot(snapshot(baselineAKey, "b-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(candidateAKey, "b-old", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot(currentAKey, "b-external", 19_002, currentAExternal).authStores[0]!.store, agentDir, @@ -661,15 +571,7 @@ describe("secrets runtime state", () => { profileIds: ["provider-b:default"], }); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles; if (expectedAKey === null) { expect(restored?.["provider-a:default"]).toBeUndefined(); @@ -690,44 +592,18 @@ describe("secrets runtime state", () => { { label: "inherited profile", runtimeLocalProfileIds: [], expected: "sk-candidate" }, ])("uses the effective owner token for a $label", ({ runtimeLocalProfileIds, expected }) => { const agentDir = `/tmp/openclaw-auth-effective-owner-${runtimeLocalProfileIds.length}`; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - runtimeLocalProfileIds, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + runtimeLocalProfileIds, + }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, stateChanged: false, @@ -735,15 +611,7 @@ describe("secrets runtime state", () => { }); setRuntimeAuthProfileStoreSnapshot(candidate.authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: expected, }); @@ -755,27 +623,12 @@ describe("secrets runtime state", () => { profiles: AuthProfileStore["profiles"], externalProfileIds: string[], port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles, - runtimeExternalProfileIds: externalProfileIds, - }, - }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + ) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles, + runtimeExternalProfileIds: externalProfileIds, + }); const profileX = { type: "api_key" as const, provider: "openai", @@ -786,25 +639,12 @@ describe("secrets runtime state", () => { provider: "openai", key: "sk-external-y", }; - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot( - { "openai:x": profileX, "openai:y": profileY }, - ["openai:x", "openai:y"], - 19_001, - ), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot( + snapshot({ "openai:x": profileX, "openai:y": profileY }, ["openai:x", "openai:y"], 19_001), + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ "openai:y": profileY }, ["openai:y"], 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, stateChanged: false, @@ -812,15 +652,7 @@ describe("secrets runtime state", () => { }); setRuntimeAuthProfileStoreSnapshot(candidate.authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -833,49 +665,19 @@ describe("secrets runtime state", () => { "handles baseline external to $candidateOwner with mutation=$mutateCandidateOwner", ({ candidateOwner, mutateCandidateOwner }) => { const agentDir = `/tmp/openclaw-auth-external-to-${candidateOwner}-${mutateCandidateOwner}`; - const snapshot = ( - key: string, - owner: "external" | "inherited" | "local", - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:x": { type: "api_key", provider: "openai", key }, - }, - runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], - runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], - }, + const snapshot = (key: string, owner: "external" | "inherited" | "local", port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:x": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-external-old", "external", 19_001), - refreshContext: null, - refreshHandler: null, - }); + runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], + runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], + }); + activateSnapshot(snapshot("sk-external-old", "external", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateOwner, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (mutateCandidateOwner) { noteRuntimeAuthProfileStorePersistedMutation( candidateOwner === "local" ? agentDir : undefined, @@ -892,15 +694,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); if (mutateCandidateOwner) { expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); } else { @@ -919,59 +713,32 @@ describe("secrets runtime state", () => { key: string | null, owner: "external" | "inherited" | "local", port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - ...(key === null - ? {} - : { "openai:x": { type: "api_key" as const, provider: "openai", key } }), - "anthropic:stable": { - type: "api_key", - provider: "anthropic", - key: "sk-stable", - }, - }, - runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], - runtimeLocalProfileIds: [ - "anthropic:stable", - ...(owner === "local" ? ["openai:x"] : []), - ], + ) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + ...(key === null + ? {} + : { "openai:x": { type: "api_key" as const, provider: "openai", key } }), + "anthropic:stable": { + type: "api_key", + provider: "anthropic", + key: "sk-stable", }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot( + runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], + runtimeLocalProfileIds: ["anthropic:stable", ...(owner === "local" ? ["openai:x"] : [])], + }); + activateSnapshot( + snapshot( baselineOwner === "absent" ? null : "sk-baseline", baselineOwner === "local" ? "local" : "inherited", 19_001, ), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-external", "external", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation( baselineOwner === "inherited" ? undefined : agentDir, { @@ -982,15 +749,7 @@ describe("secrets runtime state", () => { ); setRuntimeAuthProfileStoreSnapshot(candidate.authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }, ); @@ -1003,74 +762,37 @@ describe("secrets runtime state", () => { key: string | null, owner: "external" | "inherited" | "local", port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - ...(key === null - ? {} - : { "openai:x": { type: "api_key" as const, provider: "openai", key } }), - "anthropic:stable": { - type: "api_key", - provider: "anthropic", - key: "sk-stable", - }, - }, - runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], - runtimeLocalProfileIds: [ - "anthropic:stable", - ...(owner === "local" ? ["openai:x"] : []), - ], + ) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + ...(key === null + ? {} + : { "openai:x": { type: "api_key" as const, provider: "openai", key } }), + "anthropic:stable": { + type: "api_key", + provider: "anthropic", + key: "sk-stable", }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], + runtimeLocalProfileIds: ["anthropic:stable", ...(owner === "local" ? ["openai:x"] : [])], + }); const baseline = snapshot( baselineOwner === "absent" ? null : "sk-baseline", baselineOwner === "local" ? "local" : "inherited", 19_001, ); - activateSecretsRuntimeSnapshotState({ - snapshot: baseline, - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(baseline); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-external", "external", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-external-refresh", "external", 19_002).authStores[0]!.store, agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir); if (baselineOwner === "absent") { expect(restored?.profiles["openai:x"]).toBeUndefined(); @@ -1088,63 +810,25 @@ describe("secrets runtime state", () => { "preserves $currentOwner owner metadata when bytes equal the $candidateOwner candidate", ({ candidateOwner, currentOwner }) => { const agentDir = `/tmp/openclaw-auth-${candidateOwner}-${currentOwner}-equal-bytes`; - const snapshot = ( - key: string, - owner: "external" | "local", - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:x": { type: "api_key", provider: "openai", key }, - }, - runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], - runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], - }, + const snapshot = (key: string, owner: "external" | "local", port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:x": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", candidateOwner, 19_001), - refreshContext: null, - refreshHandler: null, - }); + runtimeExternalProfileIds: owner === "external" ? ["openai:x"] : [], + runtimeLocalProfileIds: owner === "local" ? ["openai:x"] : [], + }); + activateSnapshot(snapshot("sk-old", candidateOwner, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateOwner, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-candidate", currentOwner, 19_002).authStores[0]!.store, agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir); expect(restored?.profiles["openai:x"]).toMatchObject({ key: "sk-candidate" }); if (currentOwner === "local") { @@ -1159,53 +843,19 @@ describe("secrets runtime state", () => { it("preserves an authoritative empty external overlay on rollback", () => { const agentDir = "/tmp/openclaw-auth-authoritative-empty-external"; - const snapshot = (authoritative: boolean, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: {}, - runtimeExternalProfileIds: [], - runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, - }, - }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(true, 19_001), - refreshContext: null, - refreshHandler: null, - }); + const snapshot = (authoritative: boolean, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: {}, + runtimeExternalProfileIds: [], + runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, + }); + activateSnapshot(snapshot(true, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(false, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toMatchObject({ runtimeExternalProfileIds: [], runtimeExternalProfileIdsAuthoritative: true, @@ -1214,64 +864,26 @@ describe("secrets runtime state", () => { it("does not import rejected external authority from a selected current credential", () => { const agentDir = "/tmp/openclaw-auth-rejected-external-authority"; - const snapshot = ( - key: string, - authoritative: boolean, - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:x": { type: "api_key", provider: "openai", key }, - }, - runtimeLocalProfileIds: ["openai:x"], - runtimeExternalProfileIds: [], - runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, - }, + const snapshot = (key: string, authoritative: boolean, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:x": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", false, 19_001), - refreshContext: null, - refreshHandler: null, - }); + runtimeLocalProfileIds: ["openai:x"], + runtimeExternalProfileIds: [], + runtimeExternalProfileIdsAuthoritative: authoritative ? true : undefined, + }); + activateSnapshot(snapshot("sk-old", false, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-old", true, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-current", true, 19_002).authStores[0]!.store, agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); const restored = getRuntimeAuthProfileStoreSnapshot(agentDir); expect(restored?.profiles["openai:x"]).toMatchObject({ key: "sk-current" }); expect(restored?.runtimeExternalProfileIdsAuthoritative).toBeUndefined(); @@ -1282,44 +894,18 @@ describe("secrets runtime state", () => { { current: "sk-external-refresh", expected: "sk-external-refresh" }, ])("keeps external profile ownership separate from main mutations", ({ current, expected }) => { const agentDir = `/tmp/openclaw-auth-external-owner-${current}`; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:external": { type: "api_key", provider: "openai", key }, - }, - runtimeExternalProfileIds: ["openai:external"], - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:external": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + runtimeExternalProfileIds: ["openai:external"], + }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, stateChanged: false, @@ -1327,15 +913,7 @@ describe("secrets runtime state", () => { }); setRuntimeAuthProfileStoreSnapshot(snapshot(current, 19_002).authStores[0]!.store, agentDir); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:external"]).toMatchObject( { key: expected, @@ -1345,49 +923,23 @@ describe("secrets runtime state", () => { it("removes a rejected candidate credential when its bounded lineage was evicted", () => { const agentDir = "/tmp/openclaw-auth-evicted-lineage"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - "anthropic:stable": { - type: "api_key", - provider: "anthropic", - key: "sk-stable", - }, - }, - runtimeLocalProfileIds: ["anthropic:stable", "openai:default"], + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, + "anthropic:stable": { + type: "api_key", + provider: "anthropic", + key: "sk-stable", }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + runtimeLocalProfileIds: ["anthropic:stable", "openai:default"], + }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); for (let index = 0; index < 300; index += 1) { noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, @@ -1396,15 +948,7 @@ describe("secrets runtime state", () => { }); } - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -1420,53 +964,23 @@ describe("secrets runtime state", () => { id: "OPENAI_API_KEY", }; const candidateRef = { ...previousRef, id: "OPENAI_API_KEY_NEXT" }; - const snapshot = ( - key: string, - keyRef: typeof previousRef, - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key, keyRef }, - }, - runtimeLocalProfileIds: ["openai:default"], - }, + const snapshot = (key: string, keyRef: typeof previousRef, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key, keyRef }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); + runtimeLocalProfileIds: ["openai:default"], + }); try { saveAuthProfileStore( snapshot("sk-old", previousRef, 19_001).authStores[0]!.store, agentDir, ); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", previousRef, 19_001), - refreshContext: null, - refreshHandler: null, - }); + activateSnapshot(snapshot("sk-old", previousRef, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateRef, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); setRuntimeAuthProfileStoreSnapshot( snapshot("sk-descendant", candidateRef, 19_002).authStores[0]!.store, agentDir, @@ -1482,15 +996,7 @@ describe("secrets runtime state", () => { ); } - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); expect( ensureAuthProfileStoreWithoutExternalProfiles(agentDir).profiles["openai:default"], @@ -1579,51 +1085,33 @@ describe("secrets runtime state", () => { expectMissing, }) => { const agentDir = `/tmp/openclaw-auth-store-removal-${label}`; - const snapshot = (includeStore: boolean, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: includeStore - ? [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key: "sk-old", + const snapshot = (includeStore: boolean, port: number) => + preparedSnapshot({ + config: { gateway: { port } }, + authStores: includeStore + ? [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: "sk-old", + }, }, + runtimeLocalProfileIds: inheritsMainProfile ? [] : ["openai:default"], + runtimeInheritsMainState: inheritsMainState, }, - runtimeLocalProfileIds: inheritsMainProfile ? [] : ["openai:default"], - runtimeInheritsMainState: inheritsMainState, }, - }, - ] - : [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(true, 19_001), - refreshContext: null, - refreshHandler: null, - }); + ] + : [], + }); + activateSnapshot(snapshot(true, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(false, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (mutationOwner !== "none") { noteRuntimeAuthProfileStorePersistedMutation( mutationOwner === "custom" ? agentDir : undefined, @@ -1635,15 +1123,7 @@ describe("secrets runtime state", () => { ); } - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); if (expectMissing) { expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); } else { @@ -1656,50 +1136,32 @@ describe("secrets runtime state", () => { it("does not resurrect a baseline external store after a new main profile is added", () => { const agentDir = "/tmp/openclaw-auth-external-store-omission-mutation"; - const snapshot = (includeStore: boolean, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: includeStore - ? [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:x": { - type: "api_key", - provider: "openai", - key: "sk-external", + const snapshot = (includeStore: boolean, port: number) => + preparedSnapshot({ + config: { gateway: { port } }, + authStores: includeStore + ? [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:x": { + type: "api_key", + provider: "openai", + key: "sk-external", + }, }, + runtimeExternalProfileIds: ["openai:x"], }, - runtimeExternalProfileIds: ["openai:x"], }, - }, - ] - : [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot(true, 19_001), - refreshContext: null, - refreshHandler: null, - }); + ] + : [], + }); + activateSnapshot(snapshot(true, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot(false, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(undefined, { credentialsChanged: true, profileSetChanged: true, @@ -1707,68 +1169,26 @@ describe("secrets runtime state", () => { profileIds: ["openai:new-main"], }); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); it("does not resurrect an auth store cleared after candidate activation", () => { const agentDir = "/tmp/openclaw-auth-post-activation-clear"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_001), - refreshContext: null, - refreshHandler: null, - }); + }); + activateSnapshot(snapshot("sk-old", 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); clearRuntimeAuthProfileStoreSnapshots(); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }); @@ -1783,66 +1203,27 @@ describe("secrets runtime state", () => { id: "OPENAI_API_KEY", }; const candidateRef = changedRef ? { ...previousRef, id: "OPENAI_API_KEY_NEXT" } : previousRef; - const snapshot = ( - key: string, - keyRef: typeof previousRef, - port: number, - ): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key, keyRef }, - }, - }, + const snapshot = (key: string, keyRef: typeof previousRef, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key, keyRef }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", previousRef, 19_001), - refreshContext: null, - refreshHandler: null, - }); + }); + activateSnapshot(snapshot("sk-old", previousRef, 19_001)); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot("sk-candidate", candidateRef, 19_002); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: snapshot("sk-refreshed", candidateRef, 19_002), + activateSnapshotIfCurrent(snapshot("sk-refreshed", candidateRef, 19_002), { expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, preserveActivationLineage: true, }), ).toBe(true); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: candidateRevision, - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous, candidate, { expectedRevision: candidateRevision }), ).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: changedRef ? "sk-old" : "sk-refreshed", @@ -1852,33 +1233,14 @@ describe("secrets runtime state", () => { it("preserves live credentials when the captured predecessor is stale", () => { const agentDir = "/tmp/openclaw-auth-stale-predecessor-rollback"; - const snapshot = (key: string, port: number): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: {}, - config: { gateway: { port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { type: "api_key", provider: "openai", key }, - }, - }, + const snapshot = (key: string, port: number) => + preparedGatewayAuthSnapshot(agentDir, port, { + version: 1, + profiles: { + "openai:default": { type: "api_key", provider: "openai", key }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot("sk-old", 19_011), - refreshContext: null, - refreshHandler: null, - }); + }); + activateSnapshot(snapshot("sk-old", 19_011)); setRuntimeAuthProfileStoreSnapshot( { version: 1, @@ -1892,24 +1254,9 @@ describe("secrets runtime state", () => { const previousRevision = getActiveSecretsRuntimeSnapshotRevision(); const candidate = snapshot("sk-live", 19_012); expect(previous).not.toBeNull(); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: previousRevision, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate, { expectedRevision: previousRevision })).toBe(true); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous!, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous!, candidate)).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_011); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)?.profiles["openai:default"]).toMatchObject({ key: "sk-live", @@ -1948,50 +1295,42 @@ describe("secrets runtime state", () => { runtimePort: number; apiKey: string; keyRef: string | typeof previousKeyRef; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { - gateway: { port: params.sourcePort }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: params.keyRef, - models: [], + }) => + preparedSnapshot({ + sourceConfig: { + gateway: { port: params.sourcePort }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: params.keyRef, + models: [], + }, }, }, }, - }, - config: { - gateway: { port: params.runtimePort }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: params.apiKey, - models: [], + config: { + gateway: { port: params.runtimePort }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: params.apiKey, + models: [], + }, }, }, }, - }, - authStores: [], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + authStores: [], + }); + activateSnapshot( + snapshot({ sourcePort: 19_021, runtimePort: 19_021, apiKey: "sk-old", keyRef: previousKeyInput, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ sourcePort: 19_022, @@ -1999,14 +1338,7 @@ describe("secrets runtime state", () => { apiKey: "sk-candidate", keyRef: candidateKeyInput, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); const providerRefresh = snapshot({ sourcePort: 19_022, @@ -2015,23 +1347,14 @@ describe("secrets runtime state", () => { keyRef: candidateKeyInput, }); expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: providerRefresh, + activateSnapshotIfCurrent(providerRefresh, { expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, preserveActivationLineage: true, }), ).toBe(true); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - ownedSnapshot: candidate, - expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous, candidate, { expectedRevision: candidateRevision }), ).toBe(true); expect(getActiveSecretsRuntimeSnapshot()?.config.gateway?.port).toBe(19_021); expect(getActiveSecretsRuntimeSnapshot()?.config.models?.providers?.openai?.apiKey).toBe( @@ -2092,80 +1415,61 @@ describe("secrets runtime state", () => { "restores resolved values when a same-ref $label was rejected", ({ keyRef, previousSourceConfig, candidateSourceConfig, evictLineage }) => { const agentDir = `/tmp/openclaw-auth-provider-dependency-${keyRef.provider}`; - const snapshot = (params: { - sourceConfig: OpenClawConfig; - apiKey: string; - port: number; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { - ...params.sourceConfig, - gateway: { port: params.port }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: keyRef, - models: [], - }, - }, - }, - }, - config: { - ...params.sourceConfig, - gateway: { port: params.port }, - models: { - providers: { - openai: { - baseUrl: "https://api.openai.com/v1", - apiKey: params.apiKey, - models: [], - }, - }, - }, - }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - keyRef, - key: params.apiKey, + const snapshot = (params: { sourceConfig: OpenClawConfig; apiKey: string; port: number }) => + preparedSnapshot({ + sourceConfig: { + ...params.sourceConfig, + gateway: { port: params.port }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: keyRef, + models: [], }, }, }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ sourceConfig: previousSourceConfig, apiKey: "sk-old", port: 19_031 }), - refreshContext: null, - refreshHandler: null, - }); + config: { + ...params.sourceConfig, + gateway: { port: params.port }, + models: { + providers: { + openai: { + baseUrl: "https://api.openai.com/v1", + apiKey: params.apiKey, + models: [], + }, + }, + }, + }, + authStores: [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + keyRef, + key: params.apiKey, + }, + }, + }, + }, + ], + }); + activateSnapshot( + snapshot({ sourceConfig: previousSourceConfig, apiKey: "sk-old", port: 19_031 }), + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ sourceConfig: candidateSourceConfig, apiKey: "sk-candidate", port: 19_032, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (evictLineage) { for (let index = 0; index < 300; index += 1) { noteRuntimeAuthProfileStorePersistedMutation(agentDir, { @@ -2177,27 +1481,21 @@ describe("secrets runtime state", () => { } const candidateRevision = getActiveSecretsRuntimeSnapshotRevision(); expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: snapshot({ + activateSnapshotIfCurrent( + snapshot({ sourceConfig: candidateSourceConfig, apiKey: "sk-refreshed", port: 19_032, }), - expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, - preserveActivationLineage: true, - }), + { + expectedRevision: candidateRevision, + preserveActivationLineage: true, + }, + ), ).toBe(true); expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - ownedSnapshot: candidate, - expectedRevision: candidateRevision, - refreshContext: null, - refreshHandler: null, - }), + restoreSnapshotIfCurrent(previous, candidate, { expectedRevision: candidateRevision }), ).toBe(true); const restored = getActiveSecretsRuntimeSnapshot(); expect(restored?.sourceConfig).toMatchObject(previousSourceConfig); @@ -2233,49 +1531,41 @@ describe("secrets runtime state", () => { owner: "inherited" | "local"; providerPath: string; port: number; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { - gateway: { port: params.port }, - secrets: { - providers: { vault: { source: "file", path: params.providerPath } }, - }, - }, - config: { gateway: { port: params.port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key: params.key, - keyRef, - }, - }, - runtimeLocalProfileIds: params.owner === "local" ? ["openai:default"] : [], + }) => + preparedSnapshot({ + sourceConfig: { + gateway: { port: params.port }, + secrets: { + providers: { vault: { source: "file", path: params.providerPath } }, }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + config: { gateway: { port: params.port } }, + authStores: [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: params.key, + keyRef, + }, + }, + runtimeLocalProfileIds: params.owner === "local" ? ["openai:default"] : [], + }, + }, + ], + }); + activateSnapshot( + snapshot({ key: "sk-old", owner: capturedOwner, providerPath: "/tmp/old-secrets.json", port: 19_041, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ key: "sk-candidate", @@ -2283,14 +1573,7 @@ describe("secrets runtime state", () => { providerPath: "/tmp/rejected-secrets.json", port: 19_042, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, stateChanged: false, @@ -2306,15 +1589,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }, ); @@ -2357,44 +1632,36 @@ describe("secrets runtime state", () => { keyRef: SecretRef; port: number; sourceConfig: OpenClawConfig; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { ...params.sourceConfig, gateway: { port: params.port } }, - config: { gateway: { port: params.port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "openai:default": { - type: "api_key", - provider: "openai", - key: params.key, - keyRef: params.keyRef, + }) => + preparedSnapshot({ + sourceConfig: { ...params.sourceConfig, gateway: { port: params.port } }, + config: { gateway: { port: params.port } }, + authStores: [ + { + agentDir, + store: { + version: 1, + profiles: { + "openai:default": { + type: "api_key", + provider: "openai", + key: params.key, + keyRef: params.keyRef, + }, }, + runtimeLocalProfileIds: ["openai:default"], }, - runtimeLocalProfileIds: ["openai:default"], }, - }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + ], + }); + activateSnapshot( + snapshot({ key: "sk-old", keyRef: previousRef, port: 19_051, sourceConfig: previousSourceConfig, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ key: "sk-candidate", @@ -2402,14 +1669,7 @@ describe("secrets runtime state", () => { port: 19_052, sourceConfig: candidateSourceConfig, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, stateChanged: false, @@ -2425,15 +1685,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); if (affectedProvider) { expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); } else { @@ -2448,84 +1700,65 @@ describe("secrets runtime state", () => { "invalidates an absent-profile $currentOwner upsert under a rejected provider", (currentOwner) => { const agentDir = `/tmp/openclaw-auth-provider-absent-upsert-${currentOwner}`; - const snapshot = (params: { - includeProfile: boolean; - providerPath: string; - port: number; - }): PreparedSecretsRuntimeSnapshot => ({ - sourceConfig: { - gateway: { port: params.port }, - secrets: { - providers: { vault: { source: "file", path: params.providerPath } }, - }, - }, - config: { gateway: { port: params.port } }, - authStores: [ - { - agentDir, - store: { - version: 1, - profiles: { - "anthropic:stable": { - type: "api_key", - provider: "anthropic", - key: "sk-stable", - }, - ...(params.includeProfile - ? { - "openai:default": { - type: "api_key" as const, - provider: "openai", - key: "sk-current", - keyRef: { - source: "file" as const, - provider: "vault", - id: "openai-b", - }, - }, - } - : {}), - }, - runtimeExternalProfileIds: - params.includeProfile && currentOwner === "external" ? ["openai:default"] : [], - runtimeLocalProfileIds: [ - "anthropic:stable", - ...(params.includeProfile && currentOwner === "local" ? ["openai:default"] : []), - ], + const snapshot = (params: { includeProfile: boolean; providerPath: string; port: number }) => + preparedSnapshot({ + sourceConfig: { + gateway: { port: params.port }, + secrets: { + providers: { vault: { source: "file", path: params.providerPath } }, }, }, - ], - authStoreCredentialsRevision: getRuntimeAuthProfileStoreCredentialsRevision(), - warnings: [], - webTools: { - search: { providerSource: "none", diagnostics: [] }, - fetch: { providerSource: "none", diagnostics: [] }, - diagnostics: [], - }, - }); - activateSecretsRuntimeSnapshotState({ - snapshot: snapshot({ + config: { gateway: { port: params.port } }, + authStores: [ + { + agentDir, + store: { + version: 1, + profiles: { + "anthropic:stable": { + type: "api_key", + provider: "anthropic", + key: "sk-stable", + }, + ...(params.includeProfile + ? { + "openai:default": { + type: "api_key" as const, + provider: "openai", + key: "sk-current", + keyRef: { + source: "file" as const, + provider: "vault", + id: "openai-b", + }, + }, + } + : {}), + }, + runtimeExternalProfileIds: + params.includeProfile && currentOwner === "external" ? ["openai:default"] : [], + runtimeLocalProfileIds: [ + "anthropic:stable", + ...(params.includeProfile && currentOwner === "local" ? ["openai:default"] : []), + ], + }, + }, + ], + }); + activateSnapshot( + snapshot({ includeProfile: false, providerPath: "/tmp/old-secrets.json", port: 19_061, }), - refreshContext: null, - refreshHandler: null, - }); + ); const previous = getActiveSecretsRuntimeSnapshot()!; const candidate = snapshot({ includeProfile: false, providerPath: "/tmp/rejected-secrets.json", port: 19_062, }); - expect( - activateSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: candidate, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(activateSnapshotIfCurrent(candidate)).toBe(true); if (currentOwner === "local") { noteRuntimeAuthProfileStorePersistedMutation(agentDir, { credentialsChanged: true, @@ -2543,15 +1776,7 @@ describe("secrets runtime state", () => { agentDir, ); - expect( - restoreSecretsRuntimeSnapshotStateIfCurrent({ - snapshot: previous, - expectedRevision: getActiveSecretsRuntimeSnapshotRevision(), - ownedSnapshot: candidate, - refreshContext: null, - refreshHandler: null, - }), - ).toBe(true); + expect(restoreSnapshotIfCurrent(previous, candidate)).toBe(true); expect(getRuntimeAuthProfileStoreSnapshot(agentDir)).toBeUndefined(); }, ); diff --git a/src/system-agent/chat-engine.test.ts b/src/system-agent/chat-engine.test.ts index 429633cbfd5f..e86da12b4945 100644 --- a/src/system-agent/chat-engine.test.ts +++ b/src/system-agent/chat-engine.test.ts @@ -326,6 +326,9 @@ afterEach(() => { } }); +const CANCEL_HINT = "Say `cancel` to stop this setup."; +const countCancelHints = (text: string) => text.split(CANCEL_HINT).length - 1; + describe("SystemAgentChatEngine", () => { it("lets only an operator arm delegated persistent writes", async () => { useTempStateDir(); @@ -2048,10 +2051,61 @@ describe("SystemAgentChatEngine", () => { const invalid = await engine.handle("banana"); expect(invalid.text).toContain("Enter port 18789"); expect(invalid.text).toContain("Port"); + expect(countCancelHints(invalid.text)).toBe(1); + expect(invalid.text.endsWith(CANCEL_HINT)).toBe(true); const done = await engine.handle("18789"); expect(done.text).toContain("telegram is configured"); }); + it("hints cancel once per message, only while a step awaits an answer", async () => { + useTempStateDir(); + const engine = new SystemAgentChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.note("Open the linked-devices screen.", "Step 1"); + await prompter.note("Scan the code shown next.", "Step 2"); + await prompter.note("Keep the phone online.", "Step 3"); + await prompter.text({ message: "Phone number" }); + await prompter.note("Linked.", "Step 4"); + }, + }); + + // Three auto-answered notes concatenate into the prompt's message; the hint + // is the message's, not each step's. + const prompt = await engine.handle("connect telegram"); + expect(prompt.text).toContain("Step 3"); + expect(prompt.text).toContain("Phone number"); + expect(countCancelHints(prompt.text)).toBe(1); + expect(prompt.text.endsWith(CANCEL_HINT)).toBe(true); + expect(engine.historySince(0).at(-1)).toEqual({ role: "assistant", text: prompt.text }); + + const done = await engine.handle("+15551230000"); + expect(done.text).toContain("Step 4"); + expect(done.text).toContain("telegram is configured"); + expect(countCancelHints(done.text)).toBe(0); + }); + + it("drops the cancel hint from the cancellation message", async () => { + useTempStateDir(); + const engine = new SystemAgentChatEngine({ + runAgentTurn: async () => null, + planWithAssistant: async () => null, + deps: { loadOverview: fakeOverviewLoader() }, + runChannelSetupWizard: async (_channel: string, prompter: WizardPrompter) => { + await prompter.text({ message: "Bot token" }); + }, + }); + + const prompt = await engine.handle("connect discord"); + expect(countCancelHints(prompt.text)).toBe(1); + + const cancelled = await engine.handle("cancel"); + expect(cancelled.text).toContain("cancelled"); + expect(countCancelHints(cancelled.text)).toBe(0); + }); + it("cancels a hosted wizard mid-flight", async () => { useTempStateDir(); const engine = new SystemAgentChatEngine({ diff --git a/src/system-agent/chat-engine.ts b/src/system-agent/chat-engine.ts index 59be5208a584..db8fff76174e 100644 --- a/src/system-agent/chat-engine.ts +++ b/src/system-agent/chat-engine.ts @@ -4,7 +4,7 @@ import { isSensitiveConfigPath } from "../config/sensitive-paths.js"; import { formatErrorMessage } from "../infra/errors.js"; import { createSubsystemLogger } from "../logging/subsystem.js"; import type { RuntimeEnv } from "../runtime.js"; -import { WizardSession, type WizardStep } from "../wizard/session.js"; +import { WizardSession, wizardStepAwaitsInput, type WizardStep } from "../wizard/session.js"; import type { MemoryImportProviderOutcome, SetupMemoryImportOutcome, @@ -535,10 +535,11 @@ function renderWizardStep(step: WizardStep): string { default: break; } - lines.push("Say `cancel` to stop this setup."); return lines.filter(Boolean).join("\n"); } +const WIZARD_CANCEL_HINT = "Say `cancel` to stop this setup."; + /** Map a chat reply to a wizard step answer; null means "could not parse". */ function parseWizardAnswer(step: WizardStep, text: string): { value: unknown } | null { const trimmed = text.trim(); @@ -743,7 +744,15 @@ export class SystemAgentChatEngine { // Snapshot before resolving: wizard answers to sensitive steps (tokens, // passwords) must never enter the AI-visible history. const sensitiveTurn = this.wizardBridge?.step?.sensitive === true; - const reply = await this.resolveTurn(text, options); + const resolved = await this.resolveTurn(text, options); + // The hint belongs to the outgoing message, not to each rendered step: one + // turn can concatenate several auto-answered notes, and a wizard that just + // ended must not offer a cancel that can no longer happen. + const awaitedStep = this.wizardBridge?.step; + const reply: SystemAgentChatReply = + resolved.text && awaitedStep && wizardStepAwaitsInput(awaitedStep) + ? { ...resolved, text: `${resolved.text}\n${WIZARD_CANCEL_HINT}` } + : resolved; this.history.push({ role: "user", text: sensitiveTurn ? "" : redactSensitiveCommandText(text), diff --git a/src/tasks/task-registry-record-api.ts b/src/tasks/task-registry-record-api.ts index d881c145f1e1..ff979491b58e 100644 --- a/src/tasks/task-registry-record-api.ts +++ b/src/tasks/task-registry-record-api.ts @@ -40,16 +40,17 @@ import { tasks, tryPersistTaskUpsert, } from "./task-registry-state.js"; -import type { - JsonValue, - TaskDeliveryState, - TaskDeliveryStatus, - TaskNotifyPolicy, - TaskRecord, - TaskRuntime, - TaskScopeKind, - TaskStatus, - TaskTerminalOutcome, +import { + parseTaskNotifyPolicy, + type JsonValue, + type TaskDeliveryState, + type TaskDeliveryStatus, + type TaskNotifyPolicy, + type TaskRecord, + type TaskRuntime, + type TaskScopeKind, + type TaskStatus, + type TaskTerminalOutcome, } from "./task-registry.types.js"; import { resolveTaskCleanupAfter } from "./task-retention.js"; @@ -531,9 +532,10 @@ export function updateTaskNotifyPolicyById(params: { taskId: string; notifyPolicy: TaskNotifyPolicy; }): TaskRecord | null { + const notifyPolicy = parseTaskNotifyPolicy(params.notifyPolicy); ensureTaskRegistryReady(); return updateTask(params.taskId, { - notifyPolicy: params.notifyPolicy, + notifyPolicy, lastEventAt: Date.now(), }); } diff --git a/src/tasks/task-registry.store.test.ts b/src/tasks/task-registry.store.test.ts index d4db16617ee8..e58d4b9ddf01 100644 --- a/src/tasks/task-registry.store.test.ts +++ b/src/tasks/task-registry.store.test.ts @@ -42,7 +42,7 @@ import { loadTaskRegistryStateFromSqlite, saveTaskRegistryStateToSqlite, } from "./task-registry.store.sqlite.js"; -import type { TaskDeliveryState, TaskRecord } from "./task-registry.types.js"; +import type { TaskDeliveryState, TaskNotifyPolicy, TaskRecord } from "./task-registry.types.js"; import { parseOptionalTaskTerminalOutcome, parseTaskDeliveryStatus, @@ -355,6 +355,112 @@ describe("task-registry store runtime", () => { ); }); + it.each(["verbose", "", "state-change", "DONE_ONLY"])( + "rejects an invalid notification policy before it can poison a SQLite restart (%s)", + async (invalidPolicy) => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-invalid-notify-" }, + async () => { + resetTaskRegistryForTests(); + const created = createTaskRecord({ + runtime: "acp", + ownerKey: "agent:main:main", + scopeKind: "session", + childSessionKey: "agent:main:acp:notify-policy", + runId: "run-invalid-notify-policy", + task: "Keep the task registry readable", + status: "running", + deliveryStatus: "pending", + notifyPolicy: "done_only", + }); + const database = openOpenClawStateDatabase(); + const db = getNodeSqliteKysely(database.db); + + let mutationError: string | null = null; + try { + updateTaskNotifyPolicyById({ + taskId: created.taskId, + notifyPolicy: invalidPolicy as TaskNotifyPolicy, + }); + } catch (error) { + mutationError = error instanceof Error ? error.message : String(error); + } + + const persisted = executeSqliteQueryTakeFirstSync( + database.db, + db + .selectFrom("task_runs") + .select("notify_policy") + .where("task_id", "=", created.taskId), + ); + + let restoredPolicy: TaskNotifyPolicy | null = null; + let restoreError: string | null = null; + try { + reloadTaskRegistryFromStore(); + restoredPolicy = getTaskById(created.taskId)?.notifyPolicy ?? null; + } catch (error) { + restoreError = error instanceof Error ? error.message : String(error); + } + + try { + expect({ + mutationError, + persistedPolicy: persisted?.notify_policy, + restoredPolicy, + restoreError, + }).toEqual({ + mutationError: `Invalid persisted task notify policy: ${JSON.stringify(invalidPolicy)}`, + persistedPolicy: "done_only", + restoredPolicy: "done_only", + restoreError: null, + }); + } finally { + if (persisted?.notify_policy !== "done_only") { + executeSqliteQuerySync( + database.db, + db + .updateTable("task_runs") + .set({ notify_policy: "done_only" }) + .where("task_id", "=", created.taskId), + ); + } + resetTaskRegistryForTests({ persist: false }); + } + }, + ); + }, + ); + + it.each(["done_only", "state_changes", "silent"] as const)( + "persists valid notification policy %s across a fresh SQLite restart", + async (notifyPolicy) => { + await withOpenClawTestState( + { layout: "state-only", prefix: "openclaw-task-valid-notify-" }, + async () => { + resetTaskRegistryForTests(); + const created = createTaskRecord({ + runtime: "acp", + ownerKey: "agent:main:main", + scopeKind: "session", + childSessionKey: "agent:main:acp:notify-policy", + runId: "run-valid-notify-policy", + task: "Preserve valid notification policies", + status: "running", + deliveryStatus: "pending", + notifyPolicy: "done_only", + }); + + expect( + updateTaskNotifyPolicyById({ taskId: created.taskId, notifyPolicy })?.notifyPolicy, + ).toBe(notifyPolicy); + reloadTaskRegistryFromStore(); + expect(getTaskById(created.taskId)?.notifyPolicy).toBe(notifyPolicy); + }, + ); + }, + ); + it("rejects corrupt persisted task rows during sqlite restore", async () => { await withOpenClawTestState( { layout: "state-only", prefix: "openclaw-task-store-corrupt-" }, diff --git a/src/tui/embedded-backend.test.ts b/src/tui/embedded-backend.test.ts index 2b3bac7c6184..8b589e852ccf 100644 --- a/src/tui/embedded-backend.test.ts +++ b/src/tui/embedded-backend.test.ts @@ -24,6 +24,7 @@ const projectSessionsPatchEntryMock = vi.fn(); const createSessionGoalMock = vi.fn(); const clearSessionGoalMock = vi.fn(); const getSessionGoalMock = vi.fn(); +const updateSessionGoalObjectiveMock = vi.fn(); const updateSessionGoalStatusMock = vi.fn(); const ensureRuntimePluginsLoadedMock = vi.fn(); const ensureContextWindowCacheLoadedMock = vi.fn(async () => undefined); @@ -54,13 +55,10 @@ const getRuntimeConfigMock = vi.fn(() => ({})); const loadGatewayModelCatalogMock = vi.fn( (_params?: unknown): Array<{ id: string; name: string; provider: string }> => [], ); -const readSessionMessagesAsyncMock = vi.fn( - async ( - _sessionId?: string, - _storePath?: string, - _sessionFile?: string, - _opts?: unknown, - ): Promise => [], +const readChatHistoryPageMock = vi.fn( + async (_params?: unknown): Promise<{ messages: unknown[] }> => ({ + messages: [], + }), ); type LoadSessionEntryMockResult = { cfg: Record; @@ -122,6 +120,7 @@ vi.mock("../config/sessions.js", () => ({ getSessionGoal: (...args: unknown[]) => getSessionGoalMock(...args), resolveAgentMainSessionKey: () => "agent:main:main", resolveStorePath: () => "/tmp/openclaw-sessions.json", + updateSessionGoalObjective: (...args: unknown[]) => updateSessionGoalObjectiveMock(...args), updateSessionGoalStatus: (...args: unknown[]) => updateSessionGoalStatusMock(...args), updateSessionStore: (...args: unknown[]) => updateSessionStoreMock(...args), })); @@ -178,11 +177,6 @@ vi.mock("../config/sessions/startup-migration.js", () => ({ runSessionStartupMigrationMock(...args), })); -vi.mock("../gateway/cli-session-history.js", () => ({ - augmentChatHistoryWithCliSessionImports: ({ localMessages }: { localMessages?: unknown[] }) => - localMessages ?? [], -})); - vi.mock("../gateway/chat-display-projection.js", () => ({ projectChatDisplayMessages: (messages: unknown[]) => messages, projectRecentChatDisplayMessages: (messages: unknown[]) => messages, @@ -200,6 +194,11 @@ vi.mock("../gateway/server-methods/chat.js", () => ({ replaceOversizedChatHistoryMessages: ({ messages }: { messages: unknown[] }) => ({ messages }), })); +vi.mock("../gateway/server-methods/chat-history-pages.js", () => ({ + enrichChatHistoryCompactionMarkers: (messages: unknown[]) => messages, + readChatHistoryPage: (params: unknown) => readChatHistoryPageMock(params), +})); + vi.mock("../gateway/session-utils.js", () => ({ buildGatewaySessionInfo: (params: Parameters[0]) => buildGatewaySessionInfoMock(params), @@ -242,8 +241,6 @@ vi.mock("../gateway/session-reset-service.js", () => ({ vi.mock("../gateway/session-transcript-readers.js", () => ({ capArrayByJsonBytes: (items: unknown[]) => ({ items }), - readSessionMessagesAsync: (...args: Parameters) => - readSessionMessagesAsyncMock(...args), })); vi.mock("../gateway/sessions-patch.js", () => ({ @@ -309,6 +306,7 @@ describe("EmbeddedTuiBackend", () => { clearSessionGoalMock.mockResolvedValue(false); getSessionGoalMock.mockReset(); getSessionGoalMock.mockResolvedValue({ status: "missing" }); + updateSessionGoalObjectiveMock.mockReset(); updateSessionGoalStatusMock.mockReset(); updateSessionGoalStatusMock.mockImplementation(async ({ status }: { status: string }) => ({ objective: "ship", @@ -355,8 +353,8 @@ describe("EmbeddedTuiBackend", () => { getRuntimeConfigMock.mockReturnValue({}); loadGatewayModelCatalogMock.mockReset(); loadGatewayModelCatalogMock.mockReturnValue([]); - readSessionMessagesAsyncMock.mockReset(); - readSessionMessagesAsyncMock.mockResolvedValue([]); + readChatHistoryPageMock.mockReset(); + readChatHistoryPageMock.mockResolvedValue({ messages: [] }); loadSessionEntryMock.mockReset(); loadSessionEntryMock.mockImplementation((sessionKey: string) => ({ cfg: {}, @@ -859,7 +857,10 @@ describe("EmbeddedTuiBackend", () => { sessionKey: "agent:main:main", command: "/GOAL start Ship Goal", }), - ).resolves.toEqual({ text: "Goal started: Ship Goal" }); + ).resolves.toEqual({ + text: "Goal started: Ship Goal", + continuationPrompt: "Ship Goal", + }); expect(createSessionGoalMock).toHaveBeenCalledWith({ sessionKey: "agent:main:main", storePath: "/tmp/openclaw-sessions.json", @@ -1001,7 +1002,7 @@ describe("EmbeddedTuiBackend", () => { await backend.stop(); }); - it("uses reset-archive fallback for embedded TUI history reads", async () => { + it("uses the canonical gateway projector for embedded TUI history reads", async () => { loadSessionEntryMock.mockReturnValue({ cfg: {}, canonicalKey: "agent:main:main", @@ -1014,21 +1015,19 @@ describe("EmbeddedTuiBackend", () => { await backend.loadHistory({ sessionKey: "agent:main:main" }); - expect(readSessionMessagesAsyncMock).toHaveBeenCalledWith( - { - agentId: "main", - sessionEntry: { sessionId: "sess-main" }, - sessionId: "sess-main", - sessionKey: "agent:main:main", - storePath: "/tmp/openclaw-sessions.json", - }, - { - mode: "recent", - maxMessages: 200, - maxBytes: 1024 * 1024, - allowResetArchiveFallback: true, - }, - ); + expect(readChatHistoryPageMock).toHaveBeenCalledWith({ + entry: { sessionId: "sess-main" }, + provider: "openai", + sessionId: "sess-main", + storePath: "/tmp/openclaw-sessions.json", + sessionAgentId: "main", + canonicalKey: "agent:main:main", + max: 200, + maxHistoryBytes: 100_000, + effectiveMaxChars: 100_000, + offset: undefined, + messageId: undefined, + }); }); it("loads runtime plugins for the send-path workspace before returning embedded history", async () => { diff --git a/src/tui/embedded-backend.ts b/src/tui/embedded-backend.ts index f7e1d3728b2f..53dbe5c36700 100644 --- a/src/tui/embedded-backend.ts +++ b/src/tui/embedded-backend.ts @@ -29,7 +29,7 @@ import { import { ensureRuntimePluginsLoaded } from "../agents/runtime-plugins.js"; import { readToolValidationErrorSummary } from "../agents/tool-error-summary.js"; import { resolveTextCommand } from "../auto-reply/commands-registry.js"; -import { parseGoalCommand } from "../auto-reply/reply/commands-goal.js"; +import { executeSessionGoalCommand, parseGoalCommand } from "../auto-reply/reply/commands-goal.js"; import { resolveQueueSettings } from "../auto-reply/reply/queue/settings.js"; import { DEFAULT_QUEUE_CAP, @@ -39,22 +39,10 @@ import { import type { QueueSettings } from "../auto-reply/reply/queue/types.js"; import { createDefaultDeps } from "../cli/deps.js"; import { getRuntimeConfig } from "../config/config.js"; -import { - clearSessionGoal, - createSessionGoal, - formatSessionGoalStatus, - getSessionGoal, - updateSessionGoalObjective, - updateSessionGoalStatus, -} from "../config/sessions.js"; import { applySessionPatchProjection } from "../config/sessions/session-accessor.js"; import type { OpenClawConfig } from "../config/types.openclaw.js"; import { isChatStopCommandText } from "../gateway/chat-abort.js"; -import { - projectRecentChatDisplayMessages, - resolveEffectiveChatHistoryMaxChars, -} from "../gateway/chat-display-projection.js"; -import { augmentChatHistoryWithCliSessionImports } from "../gateway/cli-session-history.js"; +import { resolveEffectiveChatHistoryMaxChars } from "../gateway/chat-display-projection.js"; import { normalizeLiveAssistantBufferedText, projectLiveAssistantBufferedText, @@ -64,7 +52,10 @@ import { } from "../gateway/live-chat-projector.js"; import { getMaxChatHistoryMessagesBytes } from "../gateway/server-constants.js"; import { - augmentChatHistoryWithCanvasBlocks, + enrichChatHistoryCompactionMarkers, + readChatHistoryPage, +} from "../gateway/server-methods/chat-history-pages.js"; +import { CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, enforceChatHistoryFinalBudget, replaceOversizedChatHistoryMessages, @@ -72,10 +63,7 @@ import { import { loadGatewayModelCatalog } from "../gateway/server-model-catalog.js"; import { createGatewaySession } from "../gateway/session-create-service.js"; import { performGatewaySessionReset } from "../gateway/session-reset-service.js"; -import { - capArrayByJsonBytes, - readSessionMessagesAsync, -} from "../gateway/session-transcript-readers.js"; +import { capArrayByJsonBytes } from "../gateway/session-transcript-readers.js"; import { buildGatewaySessionInfo, getSessionDefaults, @@ -626,36 +614,21 @@ export class EmbeddedTuiBackend implements TuiBackend { const resolvedSessionModel = resolveSessionModelRef(cfg, entry, sessionAgentId); const max = Math.min(1000, typeof opts.limit === "number" ? opts.limit : 200); const maxHistoryBytes = getMaxChatHistoryMessagesBytes(); - const localMessages = - sessionId && storePath - ? await readSessionMessagesAsync( - { - agentId: sessionAgentId, - sessionEntry: entry, - sessionId, - sessionKey: canonicalKey, - storePath, - }, - { - mode: "recent", - maxMessages: max, - maxBytes: Math.max(maxHistoryBytes * 2, 1024 * 1024), - allowResetArchiveFallback: true, - }, - ) - : []; - const rawMessages = augmentChatHistoryWithCliSessionImports({ + const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg); + const historyPage = await readChatHistoryPage({ entry, provider: resolvedSessionModel.provider, - localMessages, + sessionId, + storePath, + sessionAgentId, + canonicalKey, + max, + maxHistoryBytes, + effectiveMaxChars, + offset: undefined, + messageId: undefined, }); - const effectiveMaxChars = resolveEffectiveChatHistoryMaxChars(cfg); - const normalized = augmentChatHistoryWithCanvasBlocks( - projectRecentChatDisplayMessages(rawMessages, { - maxChars: effectiveMaxChars, - maxMessages: max, - }), - ); + const normalized = enrichChatHistoryCompactionMarkers(historyPage.messages, entry); const perMessageHardCap = Math.min(CHAT_HISTORY_MAX_SINGLE_MESSAGE_BYTES, maxHistoryBytes); const replaced = replaceOversizedChatHistoryMessages({ messages: normalized, @@ -933,103 +906,16 @@ export class EmbeddedTuiBackend implements TuiBackend { throw new Error("invalid goal command"); } - switch (parsed.action) { - case "status": { - const snapshot = await getSessionGoal({ sessionKey, storePath }); - return { text: formatSessionGoalStatus(snapshot.goal) }; - } - case "start": - case "set": - case "create": { - const objective = parsed.text.trim(); - if (!objective) { - return { text: "Usage: /goal start " }; - } - const fallbackEntry = entry ?? { sessionId: randomUUID(), updatedAt: Date.now() }; - const goal = await createSessionGoal({ - sessionKey, - storePath, - objective, - fallbackEntry, - actor: { type: "human" }, - agentId: opts.agentId, - }); - return { text: `Goal started: ${goal.objective}` }; - } - case "edit": { - const objective = parsed.text.trim(); - if (!objective) { - return { text: "Usage: /goal edit " }; - } - const goal = await updateSessionGoalObjective({ - sessionKey, - storePath, - objective, - actor: { type: "human" }, - agentId: opts.agentId, - }); - return { text: `Goal updated: ${goal.objective}` }; - } - case "pause": { - const goal = await updateSessionGoalStatus({ - sessionKey, - storePath, - status: "paused", - actor: { type: "human" }, - agentId: opts.agentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - return { text: `Goal paused: ${goal.objective}` }; - } - case "resume": { - const goal = await updateSessionGoalStatus({ - sessionKey, - storePath, - status: "active", - actor: { type: "human" }, - agentId: opts.agentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - return { text: `Goal resumed: ${goal.objective}` }; - } - case "complete": - case "done": { - const goal = await updateSessionGoalStatus({ - sessionKey, - storePath, - status: "complete", - actor: { type: "human" }, - agentId: opts.agentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - return { text: `Goal complete: ${goal.objective}\nTokens used: ${goal.tokensUsed}` }; - } - case "block": - case "blocked": { - const goal = await updateSessionGoalStatus({ - sessionKey, - storePath, - status: "blocked", - actor: { type: "human" }, - agentId: opts.agentId, - ...(parsed.text ? { note: parsed.text } : {}), - }); - return { text: `Goal blocked: ${goal.objective}` }; - } - case "clear": { - const removed = await clearSessionGoal({ - sessionKey, - storePath, - actor: { type: "human" }, - agentId: opts.agentId, - }); - return { text: removed ? "Goal cleared." : "No goal to clear." }; - } - default: - return { - text: "Usage: /goal [status] | /goal start | /goal edit | /goal pause|resume|complete|block|clear", - }; - } + const result = await executeSessionGoalCommand({ + parsed, + sessionKey, + storePath, + fallbackEntry: entry ?? { sessionId: randomUUID(), updatedAt: Date.now() }, + agentId: opts.agentId, + }); + return result.continuationPrompt + ? { text: result.text, continuationPrompt: result.continuationPrompt } + : { text: result.text }; } private enqueuePendingLocalMessage(params: { diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts index 6aeae5adaae6..1df715d7fdee 100644 --- a/src/tui/tui-backend.ts +++ b/src/tui/tui-backend.ts @@ -213,5 +213,7 @@ export type TuiBackend = { listTaskSuggestions?: () => Promise; acceptTaskSuggestion?: (taskId: string) => Promise; dismissTaskSuggestion?: (taskId: string) => Promise<{ taskId: string; dismissed: boolean }>; - runGoalCommand?: (opts: TuiGoalCommandOptions) => Promise<{ text: string }>; + runGoalCommand?: ( + opts: TuiGoalCommandOptions, + ) => Promise<{ text: string; continuationPrompt?: string }>; }; diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts index 512bd22c1506..12691f08392e 100644 --- a/src/tui/tui-command-handlers.test.ts +++ b/src/tui/tui-command-handlers.test.ts @@ -589,7 +589,9 @@ describe("tui command handlers", () => { }); it("starts local goals and sends the objective to the model", async () => { - const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started: ship" }); + const runGoalCommand = vi + .fn() + .mockResolvedValue({ text: "Goal started: ship", continuationPrompt: "ship" }); const { handleCommand, sendChat, addSystem, refreshSessionInfo, addPendingUser } = createHarness({ opts: { local: true }, @@ -613,28 +615,32 @@ describe("tui command handlers", () => { }); it("wraps command-prefixed local goal objectives before sending", async () => { - const slashRunGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started" }); + const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`; + const slashRunGoalCommand = vi + .fn() + .mockResolvedValue({ text: "Goal started", continuationPrompt: slashPrompt }); const slashHarness = createHarness({ opts: { local: true }, runGoalCommand: slashRunGoalCommand, }); await slashHarness.handleCommand("/goal start /status"); - const slashPrompt = `Pursue this goal exactly as written from this JSON string: "\\/status"`; expectSendChatFields(slashHarness.sendChat, { sessionKey: "agent:main:main", message: slashPrompt, }); expect(slashHarness.addPendingUser).toHaveBeenCalledWith(expect.any(String), slashPrompt); - const bangRunGoalCommand = vi.fn().mockResolvedValue({ text: "Goal started" }); + const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`; + const bangRunGoalCommand = vi + .fn() + .mockResolvedValue({ text: "Goal started", continuationPrompt: bangPrompt }); const bangHarness = createHarness({ opts: { local: true }, runGoalCommand: bangRunGoalCommand, }); await bangHarness.handleCommand("/goal start !npm test"); - const bangPrompt = `Pursue this goal exactly as written from this JSON string: "!npm test"`; expectSendChatFields(bangHarness.sendChat, { sessionKey: "agent:main:main", message: bangPrompt, @@ -656,7 +662,10 @@ describe("tui command handlers", () => { }); it("wraps command-prefixed local goal resume notes before sending", async () => { - const runGoalCommand = vi.fn().mockResolvedValue({ text: "Goal resumed: ship" }); + const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`; + const runGoalCommand = vi + .fn() + .mockResolvedValue({ text: "Goal resumed: ship", continuationPrompt: prompt }); const { handleCommand, sendChat, addPendingUser } = createHarness({ opts: { local: true }, runGoalCommand, @@ -664,7 +673,6 @@ describe("tui command handlers", () => { await handleCommand("/goal resume /fast off"); - const prompt = `Continue pursuing the current goal. Interpret this JSON string as the resume note: "\\/fast off"`; expectSendChatFields(sendChat, { sessionKey: "agent:main:main", message: prompt, diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts index 1df264f75916..c7c617d076a9 100644 --- a/src/tui/tui-command-handlers.ts +++ b/src/tui/tui-command-handlers.ts @@ -6,11 +6,6 @@ import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/in import { modelKey } from "../agents/model-ref-shared.js"; import { shouldForwardModelCommandToServer } from "../auto-reply/commands-registry.shared.js"; import { normalizeGroupActivation } from "../auto-reply/group-activation.js"; -import { - formatGoalContinuationPrompt, - formatGoalResumeContinuationPrompt, - parseGoalCommand, -} from "../auto-reply/reply/commands-goal.js"; import { formatThinkingLevels, isSessionDefaultDirectiveValue, @@ -125,21 +120,6 @@ function isTerminalChatSendAckSuccess(status: unknown): boolean { const TERMINAL_CHAT_SEND_FAILURE_MESSAGE = "Chat failed before the run started; try again."; -function goalContinuationPrompt(text: string): string | null { - const parsed = parseGoalCommand(text); - if (!parsed) { - return null; - } - const action = parsed.action; - if (action === "start" || action === "set" || action === "create") { - return formatGoalContinuationPrompt(parsed.text) || null; - } - if (action === "resume") { - return formatGoalResumeContinuationPrompt(parsed.text); - } - return null; -} - export function createCommandHandlers(context: CommandHandlerContext) { const { client, @@ -294,6 +274,29 @@ export function createCommandHandlers(context: CommandHandlerContext) { } }; + const applySessionSetting = async ( + patch: Omit[0], "key" | "agentId">, + success: string | ((result: SessionsPatchResult) => string), + failure: string, + after?: (result: SessionsPatchResult) => void | Promise, + ) => { + try { + const result = await patchCurrentSession(patch); + if (!result) { + return; + } + chatLog.addSystem(typeof success === "function" ? success(result) : success); + applySessionInfoFromPatch(result); + if (after) { + await after(result); + } else { + await refreshSessionInfo(); + } + } catch (err) { + chatLog.addSystem(`${failure}: ${formatTuiErrorMessage(err)}`); + } + }; + const openSelector = ( selector: { onSelect?: (item: SelectItem) => void; @@ -336,17 +339,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { }); const selector = createSearchableSelectList(items, 9); openSelector(selector, async (value) => { - try { - const result = await patchCurrentSession({ model: value }); - if (!result) { - return; - } - chatLog.addSystem(`model set to ${value}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`model set failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting({ model: value }, `model set to ${value}`, "model set failed"); }); } catch (err) { if (!isCurrentSessionSelection(selection)) { @@ -608,9 +601,8 @@ export function createCommandHandlers(context: CommandHandlerContext) { }); chatLog.addSystem(result.text); await refreshSessionInfo(); - const continuation = goalContinuationPrompt(raw); - if (continuation) { - await sendMessage(continuation); + if (result.continuationPrompt) { + await sendMessage(result.continuationPrompt); } } catch (err) { chatLog.addSystem(`goal failed: ${formatTuiErrorMessage(err)}`); @@ -654,24 +646,20 @@ export function createCommandHandlers(context: CommandHandlerContext) { } else if (!args) { await openModelSelector(); } else { - try { - const result = await patchCurrentSession({ model: args }); - if (!result) { - return; - } - const resolvedModel = result.resolved?.model; - const resolvedProvider = result.resolved?.modelProvider; - const resolvedModelRef = resolvedModel - ? resolvedProvider - ? modelKey(resolvedProvider, resolvedModel) - : resolvedModel - : args; - chatLog.addSystem(`model set to ${resolvedModelRef}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`model set failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { model: args }, + (result) => { + const resolvedModel = result.resolved?.model; + const resolvedProvider = result.resolved?.modelProvider; + const resolvedModelRef = resolvedModel + ? resolvedProvider + ? modelKey(resolvedProvider, resolvedModel) + : resolvedModel + : args; + return `model set to ${resolvedModelRef}`; + }, + "model set failed", + ); } break; case "models": @@ -691,56 +679,37 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem(`usage: /think <${levels}>`); break; } - try { - const result = await patchCurrentSession({ thinkingLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`thinking set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`think failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { thinkingLevel: args }, + `thinking set to ${args}`, + "think failed", + ); break; case "verbose": if (!args) { chatLog.addSystem(`usage: ${formatTuiLevelCommandUsage("verbose")}`); break; } - try { - const result = await patchCurrentSession({ verboseLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`verbose set to ${args}`); - applySessionInfoFromPatch(result); - if (args === "off") { - chatLog.clearTools(); - await refreshSessionInfo(); - } else { - await loadHistory(); - } - } catch (err) { - chatLog.addSystem(`verbose failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { verboseLevel: args }, + `verbose set to ${args}`, + "verbose failed", + async () => { + if (args === "off") { + chatLog.clearTools(); + await refreshSessionInfo(); + } else { + await loadHistory(); + } + }, + ); break; case "trace": if (!args) { chatLog.addSystem("usage: /trace "); break; } - try { - const result = await patchCurrentSession({ traceLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`trace set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`trace failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting({ traceLevel: args }, `trace set to ${args}`, "trace failed"); break; case "fast": if (!args || args === "status") { @@ -751,36 +720,22 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem("usage: /fast "); break; } - try { - const result = await patchCurrentSession({ - fastMode: args === "auto" ? "auto" : args === "on", - }); - if (!result) { - return; - } - chatLog.addSystem(`fast mode set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`fast failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { fastMode: args === "auto" ? "auto" : args === "on" }, + `fast mode set to ${args}`, + "fast failed", + ); break; case "reasoning": if (!args) { chatLog.addSystem(`usage: ${formatTuiLevelCommandUsage("reasoning")}`); break; } - try { - const result = await patchCurrentSession({ reasoningLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`reasoning set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`reasoning failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { reasoningLevel: args }, + `reasoning set to ${args}`, + "reasoning failed", + ); break; case "usage": { const isReset = args ? isSessionDefaultDirectiveValue(args) : false; @@ -790,19 +745,16 @@ export function createCommandHandlers(context: CommandHandlerContext) { break; } if (isReset) { - try { - const result = await patchCurrentSession({ responseUsage: null }); - if (!result) { - return; - } - chatLog.addSystem("usage footer: reset to default"); - applySessionInfoFromPatch(result); - delete state.sessionInfo.responseUsage; - delete state.sessionInfo.effectiveResponseUsage; - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`usage failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { responseUsage: null }, + "usage footer: reset to default", + "usage failed", + async () => { + delete state.sessionInfo.responseUsage; + delete state.sessionInfo.effectiveResponseUsage; + await refreshSessionInfo(); + }, + ); break; } const current = @@ -810,17 +762,7 @@ export function createCommandHandlers(context: CommandHandlerContext) { resolveResponseUsageMode(state.sessionInfo.responseUsage); const next = normalized ?? (current === "off" ? "tokens" : current === "tokens" ? "full" : "off"); - try { - const result = await patchCurrentSession({ responseUsage: next }); - if (!result) { - return; - } - chatLog.addSystem(`usage footer: ${next}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`usage failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting({ responseUsage: next }, `usage footer: ${next}`, "usage failed"); break; } case "elevated": @@ -832,17 +774,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem("usage: /elevated "); break; } - try { - const result = await patchCurrentSession({ elevatedLevel: args }); - if (!result) { - return; - } - chatLog.addSystem(`elevated set to ${args}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`elevated failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { elevatedLevel: args }, + `elevated set to ${args}`, + "elevated failed", + ); break; case "activation": { if (!args) { @@ -854,17 +790,11 @@ export function createCommandHandlers(context: CommandHandlerContext) { chatLog.addSystem("usage: /activation "); break; } - try { - const result = await patchCurrentSession({ groupActivation: activation }); - if (!result) { - return; - } - chatLog.addSystem(`activation set to ${activation}`); - applySessionInfoFromPatch(result); - await refreshSessionInfo(); - } catch (err) { - chatLog.addSystem(`activation failed: ${formatTuiErrorMessage(err)}`); - } + await applySessionSetting( + { groupActivation: activation }, + `activation set to ${activation}`, + "activation failed", + ); break; } case "new": { diff --git a/src/tui/tui-session-run-coordinator.test.ts b/src/tui/tui-session-run-coordinator.test.ts index 8c9bc1742dc9..90c1bc56ac9b 100644 --- a/src/tui/tui-session-run-coordinator.test.ts +++ b/src/tui/tui-session-run-coordinator.test.ts @@ -1,6 +1,6 @@ // Covers bounded TUI run ownership and transcript persistence coordination. import { describe, expect, it, vi } from "vitest"; -import { TuiSessionRunCoordinator } from "./tui-session-run-coordinator.js"; +import { createTuiRunIdTracker, TuiSessionRunCoordinator } from "./tui-session-run-coordinator.js"; import type { ChatEvent, TuiHistoryLoadResult, TuiStateAccess } from "./tui-types.js"; function makeState(overrides?: Partial): TuiStateAccess { @@ -65,6 +65,24 @@ function createCoordinator(overrides?: { }; } +describe("createTuiRunIdTracker", () => { + it("bounds FIFO membership and supports explicit cleanup", () => { + const tracker = createTuiRunIdTracker(); + tracker.note(""); + for (let index = 0; index <= 200; index += 1) { + tracker.note(`run-${index}`); + } + + expect(tracker.has("")).toBe(false); + expect(tracker.has("run-0")).toBe(false); + expect(tracker.has("run-200")).toBe(true); + tracker.forget("run-200"); + expect(tracker.has("run-200")).toBe(false); + tracker.clear(); + expect(tracker.has("run-100")).toBe(false); + }); +}); + describe("TuiSessionRunCoordinator", () => { it("keeps the active session run while pruning hundreds of abandoned runs", () => { const { coordinator, state } = createCoordinator({ diff --git a/src/tui/tui-session-run-coordinator.ts b/src/tui/tui-session-run-coordinator.ts index 75c23d5c5d54..83763ae367a2 100644 --- a/src/tui/tui-session-run-coordinator.ts +++ b/src/tui/tui-session-run-coordinator.ts @@ -11,6 +11,24 @@ const HISTORY_RELOAD_OWNED = 1 << 1; const HISTORY_RELOAD_DISPLAYED = 1 << 2; const HISTORY_RELOAD_GAP_RECOVERY = 1 << 3; +/** A small FIFO membership tracker for run IDs that need no lifecycle metadata. */ +export function createTuiRunIdTracker() { + const runIds = new Set(); + return { + note: (runId: string) => { + if (runId) { + runIds.add(runId); + } + if (runIds.size > MAX_TRACKED_RUNS) { + runIds.delete(runIds.values().next().value as string); + } + }, + forget: (runId: string) => void runIds.delete(runId), + has: (runId: string) => runIds.has(runId), + clear: () => runIds.clear(), + }; +} + type HistoryOwnedRun = { runId: string; result: TuiHistoryLoadResult; diff --git a/src/tui/tui.ts b/src/tui/tui.ts index d5483f8f68f3..96fc17a2f940 100644 --- a/src/tui/tui.ts +++ b/src/tui/tui.ts @@ -61,7 +61,7 @@ import { createOverlayHandlers } from "./tui-overlays.js"; import { createTuiPluginApprovalController } from "./tui-plugin-approvals.js"; import { createSessionActions } from "./tui-session-actions.js"; import { TUI_SESSION_LOOKUP_LIMIT } from "./tui-session-list-policy.js"; -import type { TuiPendingSubmit } from "./tui-submit-state.js"; +import { createTuiRunIdTracker } from "./tui-session-run-coordinator.js"; import { createEditorSubmitHandler, createSubmitBurstCoalescer, @@ -70,7 +70,6 @@ import { } from "./tui-submit.js"; import { createTuiTaskSuggestionController } from "./tui-task-suggestions.js"; import type { - AgentSummary, SessionInfo, SessionScope, TuiOptions, @@ -609,54 +608,40 @@ export async function runTui(opts: RunTuiOptions): Promise { const resolveUsableCwd = () => tryProcessCwd() ?? fallbackCwd; const emptySessionInfoDefaults = resolveEmptySessionInfoDefaults(config); const initialSessionInput = (opts.session ?? "").trim(); - let sessionScope: SessionScope = (config.session?.scope ?? "per-sender") as SessionScope; - let sessionMainKey = normalizeMainKey(config.session?.mainKey); - let agentDefaultId = resolveDefaultAgentId(config); + const sessionScope = (config.session?.scope ?? "per-sender") as SessionScope; + const sessionMainKey = normalizeMainKey(config.session?.mainKey); + const agentDefaultId = resolveDefaultAgentId(config); let currentAgentId = resolveInitialTuiAgentId({ cfg: config, fallbackAgentId: agentDefaultId, initialSessionInput, }); - let agents: AgentSummary[] = []; const agentNames = new Map(); let currentSessionKey = ""; - let initialSessionApplied = false; let rememberedSessionApplied = false; let currentSessionId: string | null = null; const sessionGenerations = new Map(); const sessionIds = new Map(); - let activeChatRunId: string | null = null; - let pendingSubmit: TuiPendingSubmit | null = null; - let historyLoaded = false; - let isConnected = false; let connectionGeneration = 0; let wasDisconnected = false; - let toolsExpanded = false; - let showThinking = false; let pairingHintShown = false; - const localRunIds = new Set(); - const localBtwRunIds = new Set(); + const localRunIds = createTuiRunIdTracker(); + const localBtwRunIds = createTuiRunIdTracker(); const deliverDefault = opts.deliver ?? false; const autoMessage = opts.message?.trim(); const thinkingLevelOverride = normalizeThinkLevel(opts.thinking); - let autoMessageSent = false; - let sessionInfo: SessionInfo = { ...emptySessionInfoDefaults }; let dynamicSlashCommands: CommandEntry[] = []; let dynamicSlashCommandsKey: string | null = null; let dynamicSlashCommandsInFlightKey: string | null = null; let dynamicSlashCommandsRequestId = 0; let dynamicSlashCommandsReady = false; let dynamicSlashCommandsRefreshTimer: ReturnType | null = null; - let lastCtrlCAt = 0; let exitRequested = false; let exitResult: TuiResult = { exitReason: "exit" }; - let activityStatus = "idle"; - let connectionStatus = isLocalMode ? "starting local runtime" : "connecting"; - let statusTimeout: NodeJS.Timeout | null = null; let statusTimer: NodeJS.Timeout | null = null; let statusStartedAt: number | null = null; - let lastActivityStatus = activityStatus; + let lastActivityStatus = "idle"; let invalidateSessionRunOwnership: () => void = () => undefined; let retireHistoryAbsentRun: (_runId: string) => void = () => undefined; @@ -669,30 +654,10 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const state: TuiStateAccess = { - get agentDefaultId() { - return agentDefaultId; - }, - set agentDefaultId(value) { - agentDefaultId = value; - }, - get sessionMainKey() { - return sessionMainKey; - }, - set sessionMainKey(value) { - sessionMainKey = value; - }, - get sessionScope() { - return sessionScope; - }, - set sessionScope(value) { - sessionScope = value; - }, - get agents() { - return agents; - }, - set agents(value) { - agents = value; - }, + agentDefaultId, + sessionMainKey, + sessionScope, + agents: [], get currentAgentId() { return currentAgentId; }, @@ -734,130 +699,19 @@ export async function runTui(opts: RunTuiOptions): Promise { set sessionGeneration(value) { writeCurrentSessionGeneration(Math.max(readCurrentSessionGeneration(), value)); }, - get activeChatRunId() { - return activeChatRunId; - }, - set activeChatRunId(value) { - activeChatRunId = value; - }, - get pendingSubmit() { - return pendingSubmit; - }, - set pendingSubmit(value) { - pendingSubmit = value; - }, - get historyLoaded() { - return historyLoaded; - }, - set historyLoaded(value) { - historyLoaded = value; - }, - get sessionInfo() { - return sessionInfo; - }, - set sessionInfo(value) { - sessionInfo = value; - }, - get initialSessionApplied() { - return initialSessionApplied; - }, - set initialSessionApplied(value) { - initialSessionApplied = value; - }, - get isConnected() { - return isConnected; - }, - set isConnected(value) { - isConnected = value; - }, - get autoMessageSent() { - return autoMessageSent; - }, - set autoMessageSent(value) { - autoMessageSent = value; - }, - get toolsExpanded() { - return toolsExpanded; - }, - set toolsExpanded(value) { - toolsExpanded = value; - }, - get showThinking() { - return showThinking; - }, - set showThinking(value) { - showThinking = value; - }, - get connectionStatus() { - return connectionStatus; - }, - set connectionStatus(value) { - connectionStatus = value; - }, - get activityStatus() { - return activityStatus; - }, - set activityStatus(value) { - activityStatus = value; - }, - get statusTimeout() { - return statusTimeout; - }, - set statusTimeout(value) { - statusTimeout = value; - }, - get lastCtrlCAt() { - return lastCtrlCAt; - }, - set lastCtrlCAt(value) { - lastCtrlCAt = value; - }, - }; - - const noteLocalRunId = (runId: string) => { - if (!runId) { - return; - } - localRunIds.add(runId); - if (localRunIds.size > 200) { - const [first] = localRunIds; - if (first) { - localRunIds.delete(first); - } - } - }; - - const forgetLocalRunId = (runId: string) => { - localRunIds.delete(runId); - }; - - const isLocalRunId = (runId: string) => localRunIds.has(runId); - - const clearLocalRunIds = () => { - localRunIds.clear(); - }; - - const noteLocalBtwRunId = (runId: string) => { - if (!runId) { - return; - } - localBtwRunIds.add(runId); - if (localBtwRunIds.size > 200) { - const [first] = localBtwRunIds; - if (first) { - localBtwRunIds.delete(first); - } - } - }; - - const forgetLocalBtwRunId = (runId: string) => { - localBtwRunIds.delete(runId); - }; - - const isLocalBtwRunId = (runId: string) => localBtwRunIds.has(runId); - - const clearLocalBtwRunIds = () => { - localBtwRunIds.clear(); + activeChatRunId: null, + pendingSubmit: null, + historyLoaded: false, + sessionInfo: { ...emptySessionInfoDefaults }, + initialSessionApplied: false, + isConnected: false, + autoMessageSent: false, + toolsExpanded: false, + showThinking: false, + connectionStatus: isLocalMode ? "starting local runtime" : "connecting", + activityStatus: "idle", + statusTimeout: null, + lastCtrlCAt: 0, }; let client: TuiBackend; @@ -920,17 +774,17 @@ export async function runTui(opts: RunTuiOptions): Promise { root.addChild(footer); root.addChild(editor); - const resolveDynamicSlashCommandsKey = () => currentAgentId; + const resolveDynamicSlashCommandsKey = () => state.currentAgentId; const applyAutocompleteProvider = () => { const dynamicKey = resolveDynamicSlashCommandsKey(); const slashCommands = getSlashCommands({ cfg: config, local: isLocalMode, - provider: sessionInfo.modelProvider, - model: sessionInfo.model, - agentRuntime: sessionInfo.agentRuntime?.id, - thinkingLevels: sessionInfo.thinkingLevels, + provider: state.sessionInfo.modelProvider, + model: state.sessionInfo.model, + agentRuntime: state.sessionInfo.agentRuntime?.id, + thinkingLevels: state.sessionInfo.thinkingLevels, dynamicCommands: dynamicSlashCommandsKey === dynamicKey ? dynamicSlashCommands : [], }); editor.shouldSubmitAutocomplete = (text) => @@ -953,7 +807,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const key = resolveDynamicSlashCommandsKey(); if ( !dynamicSlashCommandsReady || - !isConnected || + !state.isConnected || !client.listCommands || dynamicSlashCommandsKey === key || dynamicSlashCommandsInFlightKey === key @@ -962,7 +816,7 @@ export async function runTui(opts: RunTuiOptions): Promise { } dynamicSlashCommandsInFlightKey = key; const requestId = ++dynamicSlashCommandsRequestId; - const agentId = currentAgentId; + const agentId = state.currentAgentId; void client .listCommands({ agentId, @@ -1024,9 +878,9 @@ export async function runTui(opts: RunTuiOptions): Promise { const resolveSessionKey = (raw?: string) => { return resolveTuiSessionKey({ raw, - sessionScope, - currentAgentId, - sessionMainKey, + sessionScope: state.sessionScope, + currentAgentId: state.currentAgentId, + sessionMainKey: state.sessionMainKey, }); }; @@ -1036,8 +890,8 @@ export async function runTui(opts: RunTuiOptions): Promise { const parsed = parseAgentSessionKey(sessionKey); return buildTuiLastSessionScopeKey({ connectionUrl: client.connection.url, - agentId: parsed?.agentId ?? currentAgentId, - sessionScope, + agentId: parsed?.agentId ?? state.currentAgentId, + sessionScope: state.sessionScope, }); }; @@ -1059,7 +913,11 @@ export async function runTui(opts: RunTuiOptions): Promise { const remembered = await readTuiLastSessionKey({ scopeKey: buildLastSessionScopeKeyFor(), }); - if (expectedConnectionGeneration !== connectionGeneration || !isConnected || exitRequested) { + if ( + expectedConnectionGeneration !== connectionGeneration || + !state.isConnected || + exitRequested + ) { return; } const rememberedKey = remembered ? resolveSessionKey(remembered) : null; @@ -1068,7 +926,7 @@ export async function runTui(opts: RunTuiOptions): Promise { return; } const rememberedAgent = parseAgentSessionKey(rememberedKey)?.agentId; - if (rememberedAgent && normalizeAgentId(rememberedAgent) !== currentAgentId) { + if (rememberedAgent && normalizeAgentId(rememberedAgent) !== state.currentAgentId) { rememberedSessionApplied = true; return; } @@ -1078,13 +936,13 @@ export async function runTui(opts: RunTuiOptions): Promise { search: rememberedKey, includeGlobal: false, includeUnknown: false, - agentId: currentAgentId, + agentId: state.currentAgentId, }) .catch(() => null); if ( !sessions || expectedConnectionGeneration !== connectionGeneration || - !isConnected || + !state.isConnected || exitRequested ) { return; @@ -1093,7 +951,7 @@ export async function runTui(opts: RunTuiOptions): Promise { rememberedSessionApplied = true; const restored = resolveRememberedTuiSessionKey({ rememberedKey, - currentAgentId, + currentAgentId: state.currentAgentId, sessions: sessions.sessions, }); if (!restored || restored === currentSessionKey) { @@ -1106,7 +964,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const updateHeader = () => { const sessionLabel = formatSessionKey(currentSessionKey); - const agentLabel = formatAgentLabel(currentAgentId); + const agentLabel = formatAgentLabel(state.currentAgentId); const title = opts.title ?? "openclaw tui"; header.setText( theme.header( @@ -1164,21 +1022,21 @@ export async function runTui(opts: RunTuiOptions): Promise { } const elapsed = formatElapsed(statusStartedAt); - if (activityStatus === "waiting") { + if (state.activityStatus === "waiting") { waitingTick++; statusLoader.setMessage( buildWaitingStatusMessage({ theme, tick: waitingTick, elapsed, - connectionStatus, + connectionStatus: state.connectionStatus, phrases: waitingPhrase ? [waitingPhrase] : undefined, }), ); return; } - statusLoader.setMessage(`${activityStatus} • ${elapsed} | ${connectionStatus}`); + statusLoader.setMessage(`${state.activityStatus} • ${elapsed} | ${state.connectionStatus}`); }; const startStatusTimer = () => { @@ -1186,7 +1044,7 @@ export async function runTui(opts: RunTuiOptions): Promise { return; } statusTimer = setInterval(() => { - if (!isTuiBusyActivityStatus(activityStatus)) { + if (!isTuiBusyActivityStatus(state.activityStatus)) { return; } updateBusyStatusMessage(); @@ -1202,11 +1060,11 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const stopStatusTimeout = () => { - if (!statusTimeout) { + if (!state.statusTimeout) { return; } - clearTimeout(statusTimeout); - statusTimeout = null; + clearTimeout(state.statusTimeout); + state.statusTimeout = null; }; const startWaitingTimer = () => { @@ -1223,7 +1081,7 @@ export async function runTui(opts: RunTuiOptions): Promise { waitingTick = 0; waitingTimer = setInterval(() => { - if (activityStatus !== "waiting") { + if (state.activityStatus !== "waiting") { return; } updateBusyStatusMessage(); @@ -1250,13 +1108,13 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const renderStatus = () => { - const isBusy = isTuiBusyActivityStatus(activityStatus); + const isBusy = isTuiBusyActivityStatus(state.activityStatus); if (isBusy) { - if (!statusStartedAt || lastActivityStatus !== activityStatus) { + if (!statusStartedAt || lastActivityStatus !== state.activityStatus) { statusStartedAt = Date.now(); } ensureStatusLoader(); - if (activityStatus === "waiting") { + if (state.activityStatus === "waiting") { stopStatusTimer(); startWaitingTimer(); } else { @@ -1271,21 +1129,23 @@ export async function runTui(opts: RunTuiOptions): Promise { statusLoader?.stop(); statusLoader = null; ensureStatusText(); - const text = activityStatus ? `${connectionStatus} | ${activityStatus}` : connectionStatus; + const text = state.activityStatus + ? `${state.connectionStatus} | ${state.activityStatus}` + : state.connectionStatus; statusText?.setText(theme.dim(text)); } - lastActivityStatus = activityStatus; + lastActivityStatus = state.activityStatus; }; const setConnectionStatus = (text: string, ttlMs?: number) => { - connectionStatus = text; + state.connectionStatus = text; renderStatus(); - if (statusTimeout) { + if (state.statusTimeout) { stopStatusTimeout(); } if (ttlMs && ttlMs > 0) { - statusTimeout = setTimeout(() => { - connectionStatus = isConnected + state.statusTimeout = setTimeout(() => { + state.connectionStatus = state.isConnected ? isLocalMode ? "local ready" : "connected" @@ -1298,7 +1158,7 @@ export async function runTui(opts: RunTuiOptions): Promise { }; const setActivityStatus = (text: string) => { - activityStatus = text; + state.activityStatus = text; renderStatus(); }; @@ -1329,7 +1189,7 @@ export async function runTui(opts: RunTuiOptions): Promise { // Codex owns its auth store; delegate when the CLI is available. const codexBin = provider === OPENAI_CODEX_PROVIDER || - (!provider && sessionInfo.modelProvider === OPENAI_CODEX_PROVIDER) + (!provider && state.sessionInfo.modelProvider === OPENAI_CODEX_PROVIDER) ? await resolveCodexCliBin() : null; @@ -1365,26 +1225,33 @@ export async function runTui(opts: RunTuiOptions): Promise { const updateFooter = () => { const sessionKeyLabel = formatSessionKey(currentSessionKey); - const sessionLabel = sessionInfo.displayName - ? `${sessionKeyLabel} (${sessionInfo.displayName})` + const sessionLabel = state.sessionInfo.displayName + ? `${sessionKeyLabel} (${state.sessionInfo.displayName})` : sessionKeyLabel; - const agentLabel = formatAgentLabel(currentAgentId); + const agentLabel = formatAgentLabel(state.currentAgentId); const modelLabel = formatModelFooter({ - model: sessionInfo.model, - thinkingLevel: thinkingLevelOverride ?? sessionInfo.thinkingLevel, + model: state.sessionInfo.model, + thinkingLevel: thinkingLevelOverride ?? state.sessionInfo.thinkingLevel, }); - const tokens = formatTokens(sessionInfo.totalTokens ?? null, sessionInfo.contextTokens ?? null); + const tokens = formatTokens( + state.sessionInfo.totalTokens ?? null, + state.sessionInfo.contextTokens ?? null, + ); const fastLabel = - sessionInfo.fastMode === "auto" ? "fast:auto" : sessionInfo.fastMode === true ? "fast" : null; - const verbose = sessionInfo.verboseLevel ?? "off"; - const reasoning = sessionInfo.reasoningLevel ?? "off"; + state.sessionInfo.fastMode === "auto" + ? "fast:auto" + : state.sessionInfo.fastMode === true + ? "fast" + : null; + const verbose = state.sessionInfo.verboseLevel ?? "off"; + const reasoning = state.sessionInfo.reasoningLevel ?? "off"; const reasoningLabel = reasoning === "on" ? "reasoning" : reasoning === "stream" ? "reasoning:stream" : null; const footerParts = [ `agent ${agentLabel}`, `session ${sessionLabel}`, modelLabel, - formatGoalFooter(sessionInfo.goal), + formatGoalFooter(state.sessionInfo.goal), fastLabel, verbose !== "off" ? `verbose ${verbose}` : null, reasoningLabel, @@ -1397,7 +1264,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const pluginApprovals = createTuiPluginApprovalController({ client, chatLog, - getAgentId: () => currentAgentId, + getAgentId: () => state.currentAgentId, getSessionKey: () => currentSessionKey, openOverlay, closeOverlay, @@ -1435,7 +1302,7 @@ export async function runTui(opts: RunTuiOptions): Promise { updateFooter, updateAutocompleteProvider, setActivityStatus, - clearLocalRunIds, + clearLocalRunIds: localRunIds.clear, rememberSessionKey: rememberCurrentSessionKey, }); const { @@ -1497,13 +1364,13 @@ export async function runTui(opts: RunTuiOptions): Promise { setActivityStatus, refreshSessionInfo, loadHistory, - noteLocalRunId, - isLocalRunId, - forgetLocalRunId, - clearLocalRunIds, - isLocalBtwRunId, - forgetLocalBtwRunId, - clearLocalBtwRunIds, + noteLocalRunId: localRunIds.note, + isLocalRunId: localRunIds.has, + forgetLocalRunId: localRunIds.forget, + clearLocalRunIds: localRunIds.clear, + isLocalBtwRunId: localBtwRunIds.has, + forgetLocalBtwRunId: localBtwRunIds.forget, + clearLocalBtwRunIds: localBtwRunIds.clear, }); retireHistoryAbsentRun = () => reconnectStreamingWatchdog(null); invalidateSessionRunOwnership = () => { @@ -1590,10 +1457,10 @@ export async function runTui(opts: RunTuiOptions): Promise { abortActive, setActivityStatus, formatSessionKey, - noteLocalRunId, - noteLocalBtwRunId, - forgetLocalRunId, - forgetLocalBtwRunId, + noteLocalRunId: localRunIds.note, + noteLocalBtwRunId: localBtwRunIds.note, + forgetLocalRunId: localRunIds.forget, + forgetLocalBtwRunId: localBtwRunIds.forget, consumeCompletedRunForPendingSend, isRunObserved, flushPendingHistoryRefreshIfIdle, @@ -1645,7 +1512,7 @@ export async function runTui(opts: RunTuiOptions): Promise { const decision = resolveTuiCtrlCAction({ hasInput: editor.getText().length > 0, now, - lastCtrlCAt, + lastCtrlCAt: state.lastCtrlCAt, exitRequested, wasDisconnected, exitWindowMs: opts.ctrlCExitWindowMs, @@ -1654,7 +1521,7 @@ export async function runTui(opts: RunTuiOptions): Promise { forceExit(); return; } - lastCtrlCAt = decision.nextLastCtrlCAt; + state.lastCtrlCAt = decision.nextLastCtrlCAt; if (decision.action === "clear") { editor.setText(""); setActivityStatus("cleared input; press ctrl+c again to exit"); @@ -1675,14 +1542,14 @@ export async function runTui(opts: RunTuiOptions): Promise { requestExit(); }; editor.onCtrlO = () => { - toolsExpanded = !toolsExpanded; - chatLog.setToolsExpanded(toolsExpanded); + state.toolsExpanded = !state.toolsExpanded; + chatLog.setToolsExpanded(state.toolsExpanded); // Ctrl+O is presentation-only; preserve busy activity so the status loader // does not disappear before the run lifecycle ends. setActivityStatus( resolveTuiToolsToggleActivityStatus({ - currentStatus: activityStatus, - toolsExpanded, + currentStatus: state.activityStatus, + toolsExpanded: state.toolsExpanded, }), ); tui.requestRender(); @@ -1697,7 +1564,7 @@ export async function runTui(opts: RunTuiOptions): Promise { void openSessionSelector(); }; editor.onCtrlT = () => { - showThinking = !showThinking; + state.showThinking = !state.showThinking; void loadHistory(); }; @@ -1746,8 +1613,8 @@ export async function runTui(opts: RunTuiOptions): Promise { } const connectedGeneration = ++connectionGeneration; const ownsConnection = () => - connectedGeneration === connectionGeneration && isConnected && !exitRequested; - isConnected = true; + connectedGeneration === connectionGeneration && state.isConnected && !exitRequested; + state.isConnected = true; pairingHintShown = false; const reconnected = wasDisconnected; wasDisconnected = false; @@ -1757,7 +1624,7 @@ export async function runTui(opts: RunTuiOptions): Promise { setConnectionStatus(isLocalMode ? "local ready" : "connected"); // A reconnect may already have restored a live run's busy status. Only // claim the status line when startup owns it, then release that exact state. - if (!isTuiBusyActivityStatus(activityStatus)) { + if (!isTuiBusyActivityStatus(state.activityStatus)) { setActivityStatus("starting up"); } void (async () => { @@ -1771,7 +1638,7 @@ export async function runTui(opts: RunTuiOptions): Promise { } if (attempt + 1 === SESSION_SUBSCRIPTION_MAX_ATTEMPTS) { chatLog.addSystem(`session event subscribe failed: ${formatTuiErrorMessage(err)}`); - if (activityStatus === "starting up") { + if (state.activityStatus === "starting up") { setActivityStatus("idle"); } setConnectionStatus("session event subscription failed"); @@ -1825,7 +1692,7 @@ export async function runTui(opts: RunTuiOptions): Promise { if (!ownsConnection()) { return; } - if (activityStatus === "starting up") { + if (state.activityStatus === "starting up") { setActivityStatus("idle"); } if (reconnected) { @@ -1838,8 +1705,8 @@ export async function runTui(opts: RunTuiOptions): Promise { tui.requestRender(); dynamicSlashCommandsReady = true; scheduleDynamicSlashCommandsRefresh(); - if (!autoMessageSent && autoMessage) { - autoMessageSent = true; + if (!state.autoMessageSent && autoMessage) { + state.autoMessageSent = true; await sendMessage(autoMessage); if (!ownsConnection()) { return; @@ -1852,7 +1719,7 @@ export async function runTui(opts: RunTuiOptions): Promise { return; } chatLog.addSystem(`startup failed: ${formatTuiErrorMessage(err)}`); - if (activityStatus === "starting up") { + if (state.activityStatus === "starting up") { setActivityStatus("idle"); } setConnectionStatus("startup failed", 5000); @@ -1865,9 +1732,9 @@ export async function runTui(opts: RunTuiOptions): Promise { return; } connectionGeneration += 1; - isConnected = false; + state.isConnected = false; wasDisconnected = true; - historyLoaded = false; + state.historyLoaded = false; dynamicSlashCommands = []; dynamicSlashCommandsKey = null; dynamicSlashCommandsInFlightKey = null; @@ -1898,7 +1765,7 @@ export async function runTui(opts: RunTuiOptions): Promise { client.onDisconnected = handleBackendDisconnected; client.onGap = (info) => { - if (exitRequested || !isConnected) { + if (exitRequested || !state.isConnected) { return; } setConnectionStatus(`event gap: expected ${info.expected}, got ${info.received}`, 5000); diff --git a/src/wizard/session.test.ts b/src/wizard/session.test.ts index 9d3af9bbca5b..6ac1ac95b03c 100644 --- a/src/wizard/session.test.ts +++ b/src/wizard/session.test.ts @@ -1,6 +1,7 @@ // Wizard session tests cover session creation and state transitions. + import { describe, expect, test, vi } from "vitest"; -import { WizardSession } from "./session.js"; +import { WizardSession, wizardStepAwaitsInput, type WizardStep } from "./session.js"; function noteRunner() { return new WizardSession(async (prompter) => { @@ -11,6 +12,21 @@ function noteRunner() { } describe("WizardSession", () => { + test.each([ + ["select", undefined, true], + ["multiselect", undefined, true], + ["text", undefined, true], + ["confirm", undefined, true], + ["action", "client", true], + ["action", "gateway", false], + ["note", undefined, false], + ["progress", undefined, false], + ] as const satisfies ReadonlyArray< + readonly [WizardStep["type"], WizardStep["executor"], boolean] + >)("classifies whether %s/%s awaits user input", (type, executor, expected) => { + expect(wizardStepAwaitsInput({ id: "step", type, executor })).toBe(expected); + }); + test("steps progress in order", async () => { const session = noteRunner(); diff --git a/src/wizard/session.ts b/src/wizard/session.ts index 904871342308..185d1f3b5e52 100644 --- a/src/wizard/session.ts +++ b/src/wizard/session.ts @@ -1,34 +1,39 @@ // Wizard session helpers track onboarding session ids and state. import { randomUUID } from "node:crypto"; +import type { WizardStep as ProtocolWizardStep } from "../../packages/gateway-protocol/src/index.js"; import { createDeferred, type Deferred } from "../shared/deferred.js"; import { WizardCancelledError, type WizardProgress, type WizardPrompter } from "./prompts.js"; // WizardSession exposes interactive setup as a step/answer protocol for remote // clients while reusing the same WizardPrompter contract as the local CLI. -type WizardStepOption = { - value: unknown; - label: string; - hint?: string; -}; +export type WizardStep = ProtocolWizardStep; -export type WizardStep = { - id: string; - type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action"; - title?: string; - message?: string; - format?: "plain"; - options?: WizardStepOption[]; - initialValue?: unknown; - placeholder?: string; - sensitive?: boolean; - executor?: "gateway" | "client"; - externalUrl?: string; - deviceCode?: { - code: string; - expiresInMinutes?: number; - message?: string; - }; -}; +type WizardStepInputRequirement = "always" | "never" | "client-executor"; + +const WIZARD_STEP_INPUT_REQUIREMENT_BY_TYPE = { + note: "never", + select: "always", + text: "always", + confirm: "always", + multiselect: "always", + progress: "never", + action: "client-executor", +} as const satisfies Record; + +/** Whether a step needs a user answer instead of client or gateway acknowledgement. */ +export function wizardStepAwaitsInput(step: WizardStep): boolean { + const requirement = WIZARD_STEP_INPUT_REQUIREMENT_BY_TYPE[step.type]; + switch (requirement) { + case "always": + return true; + case "never": + return false; + case "client-executor": + return step.executor === "client"; + } + const unhandledRequirement: never = requirement; + return unhandledRequirement; +} type WizardSessionStatus = "running" | "done" | "cancelled" | "error"; @@ -76,7 +81,12 @@ class WizardSessionPrompter implements WizardPrompter { } async note(message: string, title?: string): Promise { - await this.prompt({ type: "note", title, message, executor: "client" }); + await this.prompt({ + type: "note", + title, + message, + executor: "client", + }); } async deviceCode(params: { @@ -106,7 +116,12 @@ class WizardSessionPrompter implements WizardPrompter { } async plain(message: string): Promise { - await this.prompt({ type: "note", message, format: "plain", executor: "client" }); + await this.prompt({ + type: "note", + message, + format: "plain", + executor: "client", + }); } async select(params: { diff --git a/test/scripts/bench-cli-startup.test.ts b/test/scripts/bench-cli-startup.test.ts index a4792df914de..506d76812331 100644 --- a/test/scripts/bench-cli-startup.test.ts +++ b/test/scripts/bench-cli-startup.test.ts @@ -462,6 +462,18 @@ describe("bench-cli-startup", () => { args: ["gateway", "health", "--json"], presets: ["real"], }, + { + id: "gatewayHealthJsonConnected", + name: "gateway health --json (connected)", + args: ["gateway", "health", "--json"], + presets: [], + }, + { + id: "gatewayHealthJsonFirstDevice", + name: "gateway health --json (first device)", + args: ["gateway", "health", "--json"], + presets: [], + }, { id: "health", name: "health", args: ["health"], presets: ["startup", "real"] }, { id: "healthJson", @@ -485,16 +497,22 @@ describe("bench-cli-startup", () => { expect(testing.parseGatewayPortEnv("::1")).toBe(32123); expect(testing.parseGatewayPortEnv("[::1]")).toBe(32123); - expect( - withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () => - testing.buildConfigFixture({ - id: "gatewayHealthJson", - name: "gateway health --json", - args: ["gateway", "health", "--json"], - presets: ["real"], - }), - ), - ).toMatchObject({ gateway: { port: 45678 } }); + for (const id of [ + "gatewayHealthJson", + "gatewayHealthJsonConnected", + "gatewayHealthJsonFirstDevice", + ]) { + expect( + withEnv({ OPENCLAW_GATEWAY_PORT: "45678" }, () => + testing.buildConfigFixture({ + id, + name: "gateway health --json", + args: ["gateway", "health", "--json"], + presets: [], + }), + ), + ).toMatchObject({ gateway: { port: 45678 } }); + } for (const invalid of ["45678abc", "127.0.0.1:45678abc"]) { expect(() => diff --git a/test/scripts/cli-startup-bench-spawner.test.ts b/test/scripts/cli-startup-bench-spawner.test.ts index 14c6f0f43d9b..50a84d997b61 100644 --- a/test/scripts/cli-startup-bench-spawner.test.ts +++ b/test/scripts/cli-startup-bench-spawner.test.ts @@ -34,6 +34,109 @@ describe("CLI startup benchmark script spawners", () => { ); }); + it("reuses warmed state for gateway health while isolating first-device samples", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-state-scope-test-")); + try { + const fixturePath = path.join(tmpDir, "record-home.mjs"); + const homeLogPath = path.join(tmpDir, "homes.log"); + fs.writeFileSync( + fixturePath, + [ + 'import { appendFileSync } from "node:fs";', + "appendFileSync(process.env.OPENCLAW_BENCH_HOME_LOG, `${process.env.HOME}\\n`);", + "console.log('{\"ok\":true}');", + "", + ].join("\n"), + ); + + const runCase = (caseId: string) => { + fs.rmSync(homeLogPath, { force: true }); + execFileSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/bench-cli-startup.ts", + "--entry", + fixturePath, + "--case", + caseId, + "--runs", + "2", + "--warmup", + "1", + ], + { + cwd: process.cwd(), + env: { + ...process.env, + OPENCLAW_BENCH_HOME_LOG: homeLogPath, + }, + stdio: "pipe", + }, + ); + return fs.readFileSync(homeLogPath, "utf8").trim().split("\n"); + }; + + const warmedHomes = runCase("gatewayHealthJsonConnected"); + expect(warmedHomes).toHaveLength(3); + expect(new Set(warmedHomes).size).toBe(1); + expect(warmedHomes.every((home) => !fs.existsSync(home))).toBe(true); + + for (const caseId of ["gatewayHealthJson", "gatewayHealthJsonFirstDevice"]) { + const sampleHomes = runCase(caseId); + expect(sampleHomes).toHaveLength(3); + expect(new Set(sampleHomes).size).toBe(3); + expect(sampleHomes.every((home) => !fs.existsSync(home))).toBe(true); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + + it("requires connected gateway health probes to exit successfully", () => { + const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-connected-test-")); + try { + const fixturePath = path.join(tmpDir, "transport-error.mjs"); + fs.writeFileSync( + fixturePath, + [ + 'console.log(\'{"ok":false,"gateway_transport_error":"closed"}\');', + "process.exitCode = 1;", + "", + ].join("\n"), + ); + + const runCase = (caseId: string) => + spawnSync( + process.execPath, + [ + "--import", + "tsx", + "scripts/bench-cli-startup.ts", + "--entry", + fixturePath, + "--case", + caseId, + "--runs", + "1", + "--warmup", + "0", + ], + { cwd: process.cwd(), encoding: "utf8" }, + ); + + expect(runCase("gatewayHealthJson").status).toBe(0); + for (const caseId of ["gatewayHealthJsonConnected", "gatewayHealthJsonFirstDevice"]) { + const result = runCase(caseId); + expect(result.status).toBe(1); + expect(result.stderr).toContain(`${caseId} sample 1: exited with code 1`); + } + } finally { + fs.rmSync(tmpDir, { recursive: true, force: true }); + } + }); + it("does not require unrelated fixture cases for a narrowed preset", () => { const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-bench-budget-test-")); try { diff --git a/test/scripts/docker-build-helper.test.ts b/test/scripts/docker-build-helper.test.ts index 64560863a5b2..cb468bd0b69c 100644 --- a/test/scripts/docker-build-helper.test.ts +++ b/test/scripts/docker-build-helper.test.ts @@ -2785,7 +2785,7 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh" docker_e2e_run_with_harness image-name bash -lc true docker_e2e_run_detached_with_harness image-name -test "$(wc -l <"$TMPDIR/docker-run-seen")" = 2 +[[ $(wc -l <"$TMPDIR/docker-run-seen") -eq 2 ]] `; execFileSync("bash", ["-lc", script], { encoding: "utf8" }); diff --git a/test/scripts/openclaw-performance-workflow.test.ts b/test/scripts/openclaw-performance-workflow.test.ts index 71956fc3a3c0..522df47e675e 100644 --- a/test/scripts/openclaw-performance-workflow.test.ts +++ b/test/scripts/openclaw-performance-workflow.test.ts @@ -84,7 +84,7 @@ describe("OpenClaw performance workflow", () => { it("pins the Kova evaluator with release validation contracts", () => { const workflow = readFileSync(WORKFLOW, "utf8"); - const canonicalKovaRef = "e2ff1b66e5597a0df2ddb50276257e36069513dd"; + const canonicalKovaRef = "283070760a16655b28835061774158b8b11b4aff"; const legacyKovaRef = "f3d037b5b8aacd6adf8ef1dd2ea4c1d778ec7c6c"; const install = findStep("Install OCM and Kova"); const installRun = install.run ?? ""; @@ -254,6 +254,13 @@ describe("OpenClaw performance workflow", () => { expect(run.indexOf(probeCap)).toBeLessThan(run.indexOf(boundedProbe)); }); + it("measures warmed and first-device gateway health separately", () => { + const run = findStep("Run OpenClaw source performance probes", "source_performance").run ?? ""; + + expect(run).toContain("--case gatewayHealthJsonConnected \\"); + expect(run).toContain("--case gatewayHealthJsonFirstDevice \\"); + }); + it("isolates required publication in a fresh artifact-consuming job", () => { const workflow = readWorkflow(); const publisher = workflow.jobs?.publish; diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts index c643fb6cb6a9..1afcb8f2cc7b 100644 --- a/test/scripts/package-acceptance-workflow.test.ts +++ b/test/scripts/package-acceptance-workflow.test.ts @@ -42,6 +42,48 @@ const ANDROID_RELEASE_WORKFLOW = ".github/workflows/android-release.yml"; const STABLE_MAIN_CLOSEOUT_WORKFLOW = ".github/workflows/openclaw-stable-main-closeout.yml"; const WINDOWS_NODE_RELEASE_WORKFLOW = ".github/workflows/windows-node-release.yml"; const FULL_RELEASE_VALIDATION_WORKFLOW = ".github/workflows/full-release-validation.yml"; +const FULL_RELEASE_CHILD_DISPATCHES = [ + { + jobName: "normal_ci", + kind: "ci", + nonceSuffix: "-ci", + runName: "CI", + stepName: "Dispatch and monitor CI", + workflow: "ci.yml", + }, + { + jobName: "plugin_prerelease", + kind: "plugin-prerelease", + nonceSuffix: "-plugin-prerelease", + runName: "Plugin Prerelease", + stepName: "Dispatch and monitor plugin prerelease", + workflow: "plugin-prerelease.yml", + }, + { + jobName: "release_checks", + kind: "release-checks", + nonceSuffix: "-release-checks", + runName: "OpenClaw Release Checks", + stepName: "Dispatch and monitor release checks", + workflow: "openclaw-release-checks.yml", + }, + { + jobName: "npm_telegram", + kind: "npm-telegram", + nonceSuffix: "-npm-telegram", + runName: "NPM Telegram Beta E2E", + stepName: "Dispatch and monitor npm Telegram E2E", + workflow: "npm-telegram-beta-e2e.yml", + }, + { + jobName: "performance", + kind: "performance", + nonceSuffix: "", + runName: "OpenClaw Performance", + stepName: "Dispatch and monitor OpenClaw Performance", + workflow: "openclaw-performance.yml", + }, +] as const; const REPO_ROOT = process.env.GITHUB_WORKSPACE ?? process.cwd(); const RELEASE_MAINTAINER_SKILL = resolve( REPO_ROOT, @@ -184,6 +226,192 @@ function expectTextToIncludeAll(text: string | undefined, snippets: string[]): v } } +function runFullReleaseChildDispatch( + child: (typeof FULL_RELEASE_CHILD_DISPATCHES)[number], + overrides: Record = {}, +) { + const step = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName), + child.stepName, + ); + const script = step.run; + if (!script) { + throw new Error(`Expected full release child dispatch script for ${child.jobName}`); + } + + const workdir = tempDirs.make("full-release-child-dispatch-"); + const ghPath = resolve(workdir, "gh"); + const sleepPath = resolve(workdir, "sleep"); + const callsPath = resolve(workdir, "gh-calls.jsonl"); + const statusPath = resolve(workdir, "status-polls"); + writeFileSync(callsPath, ""); + writeFileSync( + ghPath, + `#!${process.execPath} +const fs = require("node:fs"); +const args = process.argv.slice(2); +const env = process.env; +fs.appendFileSync(env.MOCK_GH_CALLS, JSON.stringify({ + args, + childWorkflowRef: env.CHILD_WORKFLOW_REF, + dispatchRunName: env.DISPATCH_RUN_NAME, +}) + "\\n"); +const jobs = JSON.parse(env.MOCK_GH_JOBS); +const conclusion = env.MOCK_GH_CONCLUSION; +const url = "https://github.com/openclaw/openclaw/actions/runs/101"; +function nextStatus() { + const statuses = JSON.parse(env.MOCK_GH_STATUSES); + let index = 0; + try { index = Number(fs.readFileSync(env.MOCK_GH_STATUS_POLLS, "utf8")); } catch {} + fs.writeFileSync(env.MOCK_GH_STATUS_POLLS, String(index + 1)); + return statuses[Math.min(index, statuses.length - 1)]; +} +if (args[0] === "workflow" && args[1] === "run") { + if (env.MOCK_GH_DISPATCH_ERROR) { + console.error(env.MOCK_GH_DISPATCH_ERROR); + process.exit(1); + } + console.log("Created workflow_dispatch event."); +} else if (args[0] === "api" && args.some((value) => value.includes("/commits/"))) { + console.log(env.MOCK_GH_CURRENT_SHA); +} else if (args[0] === "api" && args.some((value) => value.includes("/actions/workflows/") && value.endsWith("/runs"))) { + console.log(env.MOCK_GH_MATCHES); +} else if (args[0] === "api" && args.some((value) => value.includes("/jobs?"))) { + if (env.MOCK_GH_JOBS_ERROR) { + console.error(env.MOCK_GH_JOBS_ERROR); + process.exit(1); + } + jobs.forEach((job) => console.log(JSON.stringify(job))); +} else if (args[0] === "api" && args.some((value) => value.includes("/actions/runs/"))) { + if (env.MOCK_GH_STATUS_ERROR && fs.existsSync(env.MOCK_GH_STATUS_POLLS)) { + console.error(env.MOCK_GH_STATUS_ERROR); + process.exit(1); + } + console.log(JSON.stringify({ + conclusion, + head_sha: env.MOCK_GH_CHILD_SHA, + html_url: url, + status: nextStatus(), + })); +} else if (args[0] === "run" && args[1] === "view") { + const field = args[args.indexOf("--json") + 1]; + if (field === "status" && env.MOCK_GH_STATUS_ERROR) { + console.error(env.MOCK_GH_STATUS_ERROR); + process.exit(1); + } + if (field === "jobs") { + if (env.MOCK_GH_JOBS_ERROR) { + console.error(env.MOCK_GH_JOBS_ERROR); + process.exit(1); + } + const query = args[args.indexOf("--jq") + 1]; + if (query.startsWith("[.jobs")) { + console.log(JSON.stringify(jobs.filter((job) => job.status === "completed" && job.conclusion !== "success" && job.conclusion !== "skipped"))); + } else { + jobs.forEach((job) => console.log(JSON.stringify(job))); + } + } else { + console.log({ + conclusion, + headSha: env.MOCK_GH_CHILD_SHA, + status: field === "status" ? nextStatus() : undefined, + url, + }[field]); + } +} else if (args[0] !== "run" || args[1] !== "cancel") { + console.error("Unexpected mock gh invocation: " + JSON.stringify(args)); + process.exit(2); +} +`, + ); + chmodSync(ghPath, 0o755); + writeFileSync(sleepPath, "#!/bin/sh\nexit 0\n"); + chmodSync(sleepPath, 0o755); + + const parentSha = "a".repeat(40); + const defaultJobs = [ + { + conclusion: "success", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: "Verify release checks", + status: "completed", + url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + }, + ]; + const stepValues: Record = { + ALLOW_UNRELEASED_CHANGELOG: "false", + CANDIDATE_ARTIFACT_JSON: "", + CHILD_WORKFLOW_KIND: child.kind, + CHILD_WORKFLOW_REF: "main", + CODEX_PLUGIN_SPEC: "", + CROSS_OS_SUITE_FILTER: "", + FAIL_FAST: "false", + GH_TOKEN: "fixture-token", + LIVE_SUITE_FILTER: "", + MODE: "both", + PACKAGE_ACCEPTANCE_PACKAGE_SPEC: "", + PACKAGE_SPEC: "openclaw@beta", + PARENT_WORKFLOW_SHA: parentSha, + PROVIDER: "openai", + PROVIDER_MODE: "mock-openai", + RELEASE_PACKAGE_SPEC: "", + RELEASE_PROFILE: "stable", + RERUN_GROUP: "all", + RUN_RELEASE_SOAK: "false", + SCENARIO: "", + TARGET_CONTEXT_REF: "", + TARGET_REF: "main", + TARGET_SHA: "b".repeat(40), + }; + const stepEnv = Object.fromEntries( + Object.keys(step.env ?? {}).map((name) => { + const value = stepValues[name]; + if (value === undefined) { + throw new Error(`Missing child dispatch fixture value for ${child.jobName}.${name}`); + } + return [name, value]; + }), + ); + const result = spawnSync("bash", ["-c", script], { + cwd: workdir, + encoding: "utf8", + env: { + ...stepEnv, + GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN: + readWorkflow(FULL_RELEASE_VALIDATION_WORKFLOW).env + ?.GH_TRANSIENT_SERVER_OR_NETWORK_PATTERN ?? "HTTP 5[0-9][0-9]", + GITHUB_OUTPUT: resolve(workdir, "github-output"), + GITHUB_REPOSITORY: "openclaw/openclaw", + GITHUB_RUN_ATTEMPT: "2", + GITHUB_RUN_ID: "77", + GITHUB_STEP_SUMMARY: resolve(workdir, "github-summary"), + MOCK_GH_CALLS: callsPath, + MOCK_GH_CHILD_SHA: parentSha, + MOCK_GH_CONCLUSION: "success", + MOCK_GH_CURRENT_SHA: parentSha, + MOCK_GH_JOBS: JSON.stringify(defaultJobs), + MOCK_GH_MATCHES: "[101]", + MOCK_GH_STATUSES: '["completed"]', + MOCK_GH_STATUS_POLLS: statusPath, + PATH: `${workdir}:${process.env.PATH}`, + ...overrides, + }, + timeout: 10_000, + }); + const calls = readFileSync(callsPath, "utf8") + .split("\n") + .filter(Boolean) + .map( + (line) => + JSON.parse(line) as { + args: string[]; + childWorkflowRef: string; + dispatchRunName?: string; + }, + ); + return { calls, result }; +} + function runPackageAcceptanceSummary(params: { advisory?: boolean; dockerArtifactResult?: string; @@ -1205,10 +1433,10 @@ describe("package acceptance workflow", () => { it("requires full release child workflows to run at the parent workflow SHA", () => { const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); const releaseChecksWorkflow = readFileSync(RELEASE_CHECKS_WORKFLOW, "utf8"); - const performanceJob = workflow.slice( - workflow.indexOf(" performance:\n"), - workflow.indexOf("\n summary:"), - ); + const performanceJob = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, "performance"), + "Dispatch and monitor OpenClaw Performance", + ).run; expect(workflow).toContain("TARGET_SHA: ${{ needs.resolve_target.outputs.sha }}"); expect(workflow).toContain("CHILD_WORKFLOW_REF: ${{ github.ref_name }}"); @@ -1315,22 +1543,24 @@ describe("package acceptance workflow", () => { }); it("keeps child-job fail-fast polling best-effort", () => { - const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); - expect(workflow.match(/continuing with authoritative workflow conclusion\./gu)).toHaveLength(3); + for (const child of FULL_RELEASE_CHILD_DISPATCHES.slice(0, 3)) { + const dispatch = workflowStep( + workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName), + child.stepName, + ); + expect(dispatch.env?.CHILD_WORKFLOW_KIND).toBe(child.kind); + expect(dispatch.run).toContain("continuing with authoritative workflow conclusion."); + } }); it("adopts exact full-release child runs without retrying ambiguous dispatch posts", () => { - const childDispatches = [ - ["normal_ci", "Dispatch and monitor CI"], - ["plugin_prerelease", "Dispatch and monitor plugin prerelease"], - ["release_checks", "Dispatch and monitor release checks"], - ["npm_telegram", "Dispatch and monitor npm Telegram E2E"], - ["performance", "Dispatch and monitor OpenClaw Performance"], - ] as const; - const dispatchScripts = childDispatches.map(([jobName, stepName]) => { - const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, jobName); - return workflowStep(job, stepName).run ?? ""; + const dispatchScripts = FULL_RELEASE_CHILD_DISPATCHES.map((child) => { + const job = workflowJob(FULL_RELEASE_VALIDATION_WORKFLOW, child.jobName); + const step = workflowStep(job, child.stepName); + expect(step.env?.CHILD_WORKFLOW_KIND).toBe(child.kind); + return step.run ?? ""; }); + expect(new Set(dispatchScripts).size).toBe(1); for (const script of dispatchScripts) { expect(script.match(/gh workflow run/gu)).toHaveLength(1); @@ -1411,7 +1641,7 @@ describe("package acceptance workflow", () => { const workflow = readFileSync(FULL_RELEASE_VALIDATION_WORKFLOW, "utf8"); const retryCalls = workflow.split("\n").filter((line) => line.includes("gh_with_retry ")); - expect(retryCalls).toHaveLength(37); + expect(retryCalls.length).toBeGreaterThan(0); for (const call of retryCalls) { expect(call).toMatch(/gh_with_retry (api|run view)/u); } @@ -1437,6 +1667,254 @@ describe("package acceptance workflow", () => { ); }); + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "rejects moved workflow refs before dispatching $jobName", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_CURRENT_SHA: "c".repeat(40), + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("refusing dispatch."); + expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(0); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "adopts the one exact $jobName child after an ambiguous dispatch without reposting", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_DISPATCH_ERROR: "HTTP 500: Failed to run workflow dispatch", + }); + const dispatchCalls = calls.filter(({ args }) => args[0] === "workflow"); + const adoptionCall = calls.find(({ args }) => args.includes("-X")); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(0); + expect(result.stderr).toContain("adopted exact run 101"); + expect(dispatchCalls).toHaveLength(1); + expect(dispatchCalls[0]?.args.slice(0, 5)).toEqual([ + "workflow", + "run", + child.workflow, + "--ref", + "main", + ]); + expect(adoptionCall).toMatchObject({ + childWorkflowRef: "main", + dispatchRunName: `${child.runName} full-release-validation-77-2${child.nonceSuffix}`, + }); + expect(adoptionCall?.args).toContain( + "[.workflow_runs[] | select(.display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF) | .id]", + ); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "refuses duplicate exact adoption candidates for $jobName", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_MATCHES: "[101, 102]", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("Multiple runs matched"); + expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(1); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength(0); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "refuses to adopt or retry a non-transient $jobName dispatch failure", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_DISPATCH_ERROR: "HTTP 422: Validation Failed", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("refusing adoption polling"); + expect(calls.filter(({ args }) => args[0] === "workflow")).toHaveLength(1); + expect(calls.some(({ args }) => args.includes("-X"))).toBe(false); + expect(calls.some(({ args }) => args[0] === "run" && args[1] === "cancel")).toBe(false); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "cancels exactly the adopted $jobName child when its workflow SHA differs", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_CHILD_SHA: "c".repeat(40), + }); + + expect(result.status).toBe(1); + expect(result.stdout).toContain("expected parent workflow SHA"); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toEqual([ + expect.objectContaining({ args: ["run", "cancel", "101"] }), + ]); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES)( + "cancels exactly the adopted $jobName child when monitoring fails unexpectedly", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + MOCK_GH_STATUS_ERROR: "HTTP 403: Resource not accessible by integration", + }); + + expect(result.status).toBe(1); + expect(result.stderr).toContain("HTTP 403"); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toEqual([ + expect.objectContaining({ args: ["run", "cancel", "101"] }), + ]); + }, + ); + + it.each(FULL_RELEASE_CHILD_DISPATCHES.slice(0, 4))( + "cancels the exact $jobName child after its first blocking failed job", + (child) => { + const { calls, result } = runFullReleaseChildDispatch(child, { + FAIL_FAST: "true", + MOCK_GH_JOBS: JSON.stringify([ + { + conclusion: "failure", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: "Run package acceptance", + status: "completed", + url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + }, + ]), + MOCK_GH_STATUSES: JSON.stringify([ + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "completed", + ]), + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(1); + expect(result.stdout).toContain("has failed child jobs before the workflow completed"); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength(1); + }, + ); + + it("keeps CI fail-fast job lookups advisory but npm Telegram job lookups fail-closed", () => { + const overrides = { + FAIL_FAST: "true", + MOCK_GH_JOBS_ERROR: "HTTP 403: Resource not accessible by integration", + MOCK_GH_STATUSES: JSON.stringify([ + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "completed", + ]), + }; + const normalCi = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[0], overrides); + const npmTelegram = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[3], overrides); + + expect(normalCi.result.status, normalCi.result.stderr).toBe(0); + expect(normalCi.result.stdout).toContain("continuing with authoritative workflow conclusion."); + expect(npmTelegram.result.status).toBe(1); + expect( + npmTelegram.calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel"), + `${npmTelegram.result.stdout}\n${npmTelegram.result.stderr}\n${JSON.stringify(npmTelegram.calls)}`, + ).toHaveLength(1); + }); + + it.each([ + { expectedStatus: 0, jobName: "Run QA Lab parity lane (sqlite)" }, + { expectedStatus: 0, jobName: "Run QA Lab live Discord lane" }, + { expectedStatus: 0, jobName: "Run repo/live E2E validation / Docker live" }, + { + expectedStatus: 0, + jobName: "Run package acceptance / Telegram package acceptance / mock-openai", + }, + { expectedStatus: 1, jobName: "Run repo/live E2E validation / Repo E2E" }, + { expectedStatus: 1, jobName: "Run package acceptance / Verify package integrity" }, + ])("preserves beta fail-fast ownership for $jobName", ({ expectedStatus, jobName }) => { + const { calls, result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[2], { + FAIL_FAST: "true", + MOCK_GH_JOBS: JSON.stringify([ + { + conclusion: "failure", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: jobName, + status: "completed", + }, + ]), + MOCK_GH_STATUSES: JSON.stringify([ + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "in_progress", + "completed", + ]), + RELEASE_PROFILE: "beta", + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus); + expect(calls.filter(({ args }) => args[0] === "run" && args[1] === "cancel")).toHaveLength( + expectedStatus, + ); + }); + + it.each([ + { expectedStatus: 0, failOnRegression: "false", profile: "beta" }, + { expectedStatus: 1, failOnRegression: "true", profile: "stable" }, + ])( + "keeps failed product performance $profile release behavior unchanged", + ({ expectedStatus, failOnRegression, profile }) => { + const { calls, result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[4], { + MOCK_GH_CONCLUSION: "failure", + RELEASE_PROFILE: profile, + }); + const dispatch = calls.find(({ args }) => args[0] === "workflow"); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus); + expect(dispatch?.args).toContain(`fail_on_regression=${failOnRegression}`); + if (profile === "beta") { + expect(result.stdout).toContain("advisory for beta"); + } + }, + ); + + it.each([ + { expectedStatus: 0, failingJob: "Run optional live-provider check" }, + { expectedStatus: 1, failingJob: "Run package acceptance" }, + ])("keeps Tideclaw alpha package-safety lanes blocking", ({ expectedStatus, failingJob }) => { + const { result } = runFullReleaseChildDispatch(FULL_RELEASE_CHILD_DISPATCHES[2], { + CHILD_WORKFLOW_REF: "tideclaw/alpha/2026-08-01-0000Z", + MOCK_GH_CONCLUSION: "failure", + MOCK_GH_JOBS: JSON.stringify([ + { + conclusion: "success", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/201", + name: "Verify release checks", + status: "completed", + }, + { + conclusion: "failure", + html_url: "https://github.com/openclaw/openclaw/actions/runs/101/job/202", + name: failingJob, + status: "completed", + }, + ]), + }); + + expect(result.status, `${result.stdout}\n${result.stderr}`).toBe(expectedStatus); + if (expectedStatus === 0) { + expect(result.stdout).toContain("accepted Tideclaw alpha advisory lanes"); + } else { + expect(result.stdout).toContain("package-safety Tideclaw alpha release-check lane"); + } + }); + it("keeps exhaustive update migration as a separate manual package gate", () => { const workflow = readFileSync(UPDATE_MIGRATION_WORKFLOW, "utf8"); const packageWorkflow = readFileSync(PACKAGE_ACCEPTANCE_WORKFLOW, "utf8"); @@ -3115,6 +3593,7 @@ describe("package artifact reuse", () => { expect(npmTelegramJob.if).toContain("inputs.rerun_group == 'npm-telegram'"); expect(npmTelegramJob.if).not.toContain("inputs.rerun_group == 'all'"); expect(dispatchStep.env).toEqual({ + CHILD_WORKFLOW_KIND: "npm-telegram", CHILD_WORKFLOW_REF: "${{ github.ref_name }}", FAIL_FAST: "${{ inputs.fail_fast }}", GH_TOKEN: "${{ github.token }}", @@ -3126,7 +3605,8 @@ describe("package artifact reuse", () => { }); expectTextToIncludeAll(dispatchStep.run, [ 'dispatch_id="full-release-validation-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}-npm-telegram"', - 'dispatch_output="$(gh workflow run npm-telegram-beta-e2e.yml --ref "$CHILD_WORKFLOW_REF" "${args[@]}" 2>&1)"', + 'dispatch_output="$(gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@" 2>&1)"', + 'dispatch_and_wait npm-telegram-beta-e2e.yml "$dispatch_run_name" "${args[@]}"', ".display_title == env.DISPATCH_RUN_NAME and .head_branch == env.CHILD_WORKFLOW_REF", "The dispatch was not retried to avoid creating a duplicate child.", 'if [[ "$child_head_sha" != "$PARENT_WORKFLOW_SHA" ]]; then', diff --git a/test/scripts/plugin-prerelease-test-plan.test.ts b/test/scripts/plugin-prerelease-test-plan.test.ts index 82c898c03778..8cf1934cca4d 100644 --- a/test/scripts/plugin-prerelease-test-plan.test.ts +++ b/test/scripts/plugin-prerelease-test-plan.test.ts @@ -446,7 +446,9 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { expect(releaseWorkflowSource).toContain('--arg targetContextRef "$TARGET_CONTEXT_REF"'); expect(releaseWorkflowSource).toContain("targetContextRef: $targetContextRef"); expect(normalCiScript).toContain('dispatch_and_wait ci.yml "$dispatch_run_name" "${args[@]}"'); - expect(normalCiScript).not.toContain("full_release_validation=true"); + const normalCiDispatchCase = normalCiScript.match(/^\s*ci\)\n([\s\S]*?)^\s*;;$/mu)?.[1]; + expect(normalCiDispatchCase).toContain('dispatch_and_wait ci.yml "$dispatch_run_name"'); + expect(normalCiDispatchCase).not.toContain("full_release_validation=true"); expect(pluginPrereleaseScript).toContain( 'args=(-f target_ref="$TARGET_SHA" -f expected_sha="$TARGET_SHA" -f full_release_validation=true -f dispatch_id="$dispatch_id")', ); @@ -676,10 +678,19 @@ describe("scripts/lib/plugin-prerelease-test-plan.mjs", () => { default: false, type: "boolean", }); - expect( - fullReleaseSource.match(/has failed child jobs before the workflow completed/gu)?.length, - ).toBeGreaterThanOrEqual(3); - expect(fullReleaseSource.match(/if \[\[ "\$FAIL_FAST" != "true" \]\]; then/gu)?.length).toBe(4); + for (const [jobName, kind] of [ + ["normal_ci", "ci"], + ["plugin_prerelease", "plugin-prerelease"], + ["release_checks", "release-checks"], + ["npm_telegram", "npm-telegram"], + ] as const) { + const dispatch: WorkflowStep = fullReleaseWorkflow.jobs[jobName].steps[0]; + expect(dispatch.env?.CHILD_WORKFLOW_KIND).toBe(kind); + expect(dispatch.env?.FAIL_FAST).toBe("${{ inputs.fail_fast }}"); + expect(dispatch.run).toContain('if [[ "$FAIL_FAST" != "true" ]]; then'); + expect(dispatch.run).toContain("has failed child jobs before the workflow completed"); + } + expect(fullReleaseWorkflow.jobs.performance.steps[0].env).not.toHaveProperty("FAIL_FAST"); expect(fullReleaseSource).toContain('-f fail_fast="$FAIL_FAST"'); expect(fullReleaseSource).toContain( "npm-telegram-beta-e2e.yml has failed child jobs before the workflow completed; cancelling the remaining run.", diff --git a/test/scripts/release-no-push-workflow.test.ts b/test/scripts/release-no-push-workflow.test.ts index 0634754efbb4..e626f82727f9 100644 --- a/test/scripts/release-no-push-workflow.test.ts +++ b/test/scripts/release-no-push-workflow.test.ts @@ -366,7 +366,8 @@ describe("release validation no-push transport", () => { expect(fullText).toContain("dispatch_and_wait plugin-prerelease.yml"); expect(fullText).toContain("dispatch_and_wait openclaw-release-checks.yml"); - expect(fullText).toContain("gh workflow run openclaw-performance.yml"); + expect(fullText).toContain("dispatch_and_wait openclaw-performance.yml"); + expect(fullText).toContain('gh workflow run "$workflow" --ref "$CHILD_WORKFLOW_REF" "$@"'); const preparePackage = job(release, "prepare_release_package"); const live = job(release, "live_repo_e2e_release_checks"); diff --git a/test/vitest/vitest.shared.config.ts b/test/vitest/vitest.shared.config.ts index 5da7b85e5c85..33d2fbe5e696 100644 --- a/test/vitest/vitest.shared.config.ts +++ b/test/vitest/vitest.shared.config.ts @@ -5,6 +5,7 @@ import { fileURLToPath } from "node:url"; import acpCorePackageJson from "../../packages/acp-core/package.json" with { type: "json" }; import { pluginSdkSubpaths } from "../../scripts/lib/plugin-sdk-entries.mjs"; import privateLocalOnlyPluginSdkSubpaths from "../../scripts/lib/plugin-sdk-private-local-only-subpaths.json" with { type: "json" }; +import { createStateSchemaInlinePlugin } from "../../scripts/lib/state-schema-inline-plugin.mjs"; import { detectVitestHostInfo as detectVitestHostInfoImpl, isCiLikeEnv, @@ -158,6 +159,7 @@ if (!isCI && localScheduling.throttledBySystem && shouldPrintVitestThrottle(proc export const sharedVitestConfig = { root: repoRoot, envDir: false as const, + plugins: [createStateSchemaInlinePlugin(repoRoot)], resolve: { alias: [ { diff --git a/tsdown.config.ts b/tsdown.config.ts index 1ca9275f7644..2dcafbe5956d 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -11,6 +11,10 @@ import { pluginSdkEntrypoints, productionPluginSdkEntrypoints, } from "./scripts/lib/plugin-sdk-entries.mjs"; +import { + createStateSchemaInlinePlugin, + STATE_SCHEMA_INLINE_PLUGIN_NAME, +} from "./scripts/lib/state-schema-inline-plugin.mjs"; import { TSDOWN_PACKAGE_CONFIG_GROUP, TSDOWN_UNIFIED_CONFIG_GROUP, @@ -46,43 +50,7 @@ const env = { const OUTPUT_SOURCE_MAPS = process.env.OUTPUT_SOURCE_MAPS === "1"; const RUN_NODE_SKIP_DTS_BUILD = process.env.OPENCLAW_RUN_NODE_SKIP_DTS_BUILD === "1"; const TSDOWN_DECLARATIONS = !RUN_NODE_SKIP_DTS_BUILD; -export const STATE_SCHEMA_INLINE_PLUGIN_NAME = "openclaw:inline-state-schemas"; - -const STATE_SCHEMA_MODULES = [ - { - modulePath: "src/state/openclaw-state-schema.ts", - schemaPath: "src/state/openclaw-state-schema.sql", - exportName: "OPENCLAW_STATE_SCHEMA_SQL", - }, - { - modulePath: "src/state/openclaw-agent-schema.ts", - schemaPath: "src/state/openclaw-agent-schema.sql", - exportName: "OPENCLAW_AGENT_SCHEMA_SQL", - }, -] as const; - -/** Inline canonical schema bytes so packaged database opens need no SQL asset. */ -export function createStateSchemaInlinePlugin(rootDir: string = process.cwd()) { - const schemasByModulePath = new Map( - STATE_SCHEMA_MODULES.map((schema) => [path.resolve(rootDir, schema.modulePath), schema]), - ); - - return { - name: STATE_SCHEMA_INLINE_PLUGIN_NAME, - load(this: { addWatchFile(id: string): void }, id: string) { - const schema = schemasByModulePath.get(path.resolve(id)); - if (!schema) { - return null; - } - const schemaPath = path.resolve(rootDir, schema.schemaPath); - this.addWatchFile(schemaPath); - return { - code: `export const ${schema.exportName} = ${JSON.stringify(fs.readFileSync(schemaPath, "utf8"))};\n`, - moduleType: "js" as const, - }; - }, - }; -} +export { createStateSchemaInlinePlugin, STATE_SCHEMA_INLINE_PLUGIN_NAME }; const SUPPRESSED_EVAL_WARNING_PATHS = [ "@protobufjs/inquire/index.js", diff --git a/ui/src/components/session-owner-chip.ts b/ui/src/components/session-owner-chip.ts index 947af11f63ea..4f16b7428e8c 100644 --- a/ui/src/components/session-owner-chip.ts +++ b/ui/src/components/session-owner-chip.ts @@ -2,6 +2,7 @@ import { html, nothing } from "lit"; import { property } from "lit/decorators.js"; import type { SessionCreatedActor as ProtocolSessionCreatedActor } from "../../../packages/gateway-protocol/src/schema/sessions.js"; import { t } from "../i18n/index.ts"; +import { takeGraphemes } from "../lib/graphemes.ts"; import { resolveAvatar } from "../lib/identity-avatar.ts"; import { OpenClawLightDomElement } from "../lit/openclaw-element.ts"; import "./viewer-facepile.ts"; @@ -66,8 +67,11 @@ function ownerInitials(createdActor: SessionCreatedActor): string { .replace(/@.*$/u, "") .split(/[\s._-]+/u) .filter(Boolean); - const initials = ((parts[0]?.[0] ?? "") + (parts[1]?.[0] ?? "")).toUpperCase(); - return initials || source[0]!.toUpperCase(); + // Grapheme clusters, not UTF-16 units or bare code points: emoji display names + // must render their complete visible initial (no lone surrogates or split ZWJ sequences). + const firstChar = (value: string | undefined): string => (value ? takeGraphemes(value, 1) : ""); + const initials = (firstChar(parts[0]) + firstChar(parts[1])).toUpperCase(); + return initials || firstChar(source).toUpperCase(); } // Deterministic hue per identity so a person keeps one color everywhere. diff --git a/ui/src/components/wizard-step-controls.ts b/ui/src/components/wizard-step-controls.ts index 10c2f56e639a..a5412b6cb9aa 100644 --- a/ui/src/components/wizard-step-controls.ts +++ b/ui/src/components/wizard-step-controls.ts @@ -17,13 +17,31 @@ type WizardStepControlsProps = { inputId: string; onValueChange: (value: unknown) => void; onAnswer: (value: unknown, includeValue?: boolean) => void; + presentation?: "channels"; + answerLabel?: string; }; -function renderMessage(step: WizardStep) { - return step.message ? html`
${step.message}
` : nothing; +function stepClass(props: WizardStepControlsProps, name: string): string { + return `${props.presentation === "channels" ? "channels-wizard" : "wizard-step"}__${name}`; } -function renderOptionBody(option: WizardStepOption) { +function renderMessage(props: WizardStepControlsProps) { + return props.step.message + ? html`
${props.step.message}
` + : nothing; +} + +function renderOptionBody(option: WizardStepOption, presentation?: "channels", selected?: boolean) { + if (presentation === "channels") { + return html` + + ${selected === undefined ? nothing : selected ? "☑ " : "☐ "}${option.label} + + ${option.hint + ? html`${option.hint}` + : nothing} + `; + } return html` ${option.label} @@ -59,32 +77,95 @@ function renderDeviceCode(step: WizardStep) { `; } +function renderAnswerButton( + props: WizardStepControlsProps, + label: string, + onClick?: () => void, + disabled = props.busy, +) { + const button = html` + + `; + return props.presentation === "channels" + ? html`` + : button; +} + +function renderOption( + props: WizardStepControlsProps, + option: WizardStepOption, + index: number, + selected: unknown[], +) { + const checked = selected.some((value) => Object.is(value, option.value)); + if (props.presentation === "channels") { + return props.step.type === "select" + ? html` + ${renderOptionBody(option, props.presentation)} + ` + : html``; + } + return html``; +} + function renderContinueStep(props: WizardStepControlsProps) { const step = props.step; return html` - ${renderMessage(step)} + ${renderMessage(props)} ${step.externalUrl ? html` ${t("modelSetup.wizard.openSignIn")} ` : nothing} ${renderDeviceCode(step)} - + ${renderAnswerButton(props, t("modelSetup.wizard.continue"), () => + props.onAnswer(undefined, false), + )} `; } -function renderProgressStep(step: WizardStep) { +function renderProgressStep(props: WizardStepControlsProps) { return html`
- ${renderMessage(step)} + ${renderMessage(props)}
`; } @@ -97,11 +178,14 @@ function renderTextStep(props: WizardStepControlsProps) { class="wizard-step__form" @submit=${(event: Event) => { event.preventDefault(); - props.onAnswer(value); + const input = (event.currentTarget as HTMLFormElement).elements.namedItem( + "wizard-text", + ) as HTMLInputElement | null; + props.onAnswer(props.presentation === "channels" ? (input?.value ?? "") : value); }} > ${step.message - ? html`
+ ? html`
` : nothing} @@ -115,101 +199,74 @@ function renderTextStep(props: WizardStepControlsProps) { .value=${value} ?disabled=${props.busy} @input=${(event: Event) => + props.presentation !== "channels" && props.onValueChange((event.currentTarget as HTMLInputElement).value)} /> - + ${renderAnswerButton(props, t("modelSetup.wizard.submit"))} `; } -function renderSelectStep(props: WizardStepControlsProps) { +function renderOptionsStep(props: WizardStepControlsProps) { + const options = props.step.options ?? []; + const multiple = props.step.type === "multiselect"; + const selected = multiple ? (Array.isArray(props.value) ? props.value : []) : [props.value]; + if (props.presentation === "channels" && !multiple) { + const selectedIndex = options.findIndex((option) => Object.is(option.value, props.value)); + return html` + = 0 ? String(selectedIndex) : null} + ?disabled=${props.busy} + @change=${(event: Event) => { + const index = (event.currentTarget as HTMLElement & { value?: string | number | null }) + .value; + const option = options[Number(index)]; + if (option) { + props.onAnswer(option.value); + } + }} + > + ${options.map((option, index) => renderOption(props, option, index, selected))} + + `; + } + const answer = multiple + ? props.presentation === "channels" + ? [...selected] + : selected + : props.value; return html` - ${renderMessage(props.step)} -
- ${(props.step.options ?? []).map( - (option) => html` - - `, - )} + ${renderMessage(props)} +
+ ${options.map((option, index) => renderOption(props, option, index, selected))}
- + ${renderAnswerButton( + props, + t("modelSetup.wizard.continue"), + () => props.onAnswer(answer), + props.busy || (!multiple && props.value === undefined), + )} `; } function renderConfirmStep(props: WizardStepControlsProps) { return html` - ${renderMessage(props.step)} -
- - -
- `; -} - -function renderMultiselectStep(props: WizardStepControlsProps) { - const selected = Array.isArray(props.value) ? props.value : []; - return html` - ${renderMessage(props.step)} -
- ${(props.step.options ?? []).map( - (option) => html` - - `, + ${renderMessage(props)} +
+ ${[false, true].map( + (answer) => html``, )}
- `; } @@ -225,14 +282,13 @@ export function renderWizardStepControls( case "text": return renderTextStep(props); case "select": - return renderSelectStep(props); + case "multiselect": + return renderOptionsStep(props); case "confirm": return renderConfirmStep(props); - case "multiselect": - return renderMultiselectStep(props); case "progress": return props.step.executor === "gateway" - ? renderProgressStep(props.step) + ? renderProgressStep(props) : renderContinueStep(props); // These show whatever the step supplies behind a single Continue. case "note": diff --git a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts index b8a8572221db..086bf1e2fe2c 100644 --- a/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts +++ b/ui/src/e2e/channels-whatsapp-logout.e2e.test.ts @@ -106,4 +106,146 @@ describeControlUiE2e("Control UI WhatsApp logout mocked Gateway E2E", () => { await context.close(); } }); + + it("preserves standard channel details and the complete Telegram setup wizard", async () => { + const context = await browser.newContext({ locale: "en-US", serviceWorkers: "block" }); + const page = await context.newPage(); + const channelEntries = [ + ["discord", "Discord"], + ["googlechat", "Google Chat"], + ["imessage", "iMessage"], + ["signal", "Signal"], + ["slack", "Slack"], + ["telegram", "Telegram"], + ] as const; + const running = { configured: true, running: true }; + const details: Record> = { + googlechat: { + credentialSource: "service-account", + audienceType: "url", + audience: "https://chat.example.test", + }, + signal: { baseUrl: "https://signal.example.test" }, + }; + const bot = (accountId: string, username: string) => ({ + accountId, + ...running, + probe: { bot: { username } }, + }); + const step = (id: string, type: string, values: Record = {}) => ({ + done: false, + status: "running", + step: { id, type, ...values }, + }); + const gateway = await installMockGateway(page, { + featureMethods: ["channels.status", "channels.pairing.list", "wizard.start", "wizard.next"], + methodResponses: { + "channels.status": { + ts: Date.now(), + channelOrder: channelEntries.map(([id]) => id), + channelLabels: Object.fromEntries(channelEntries), + channelMeta: channelEntries.map(([id, label]) => ({ id, label })), + channels: Object.fromEntries( + channelEntries.map(([id]) => [id, { ...running, ...details[id] }]), + ), + channelAccounts: { telegram: [bot("personal", "alpha_bot"), bot("work", "work_bot")] }, + channelDefaultAccountId: { telegram: "personal" }, + }, + "channels.pairing.list": { + accounts: [], + requests: [], + commandOwnerConfigured: true, + limits: { pendingPerAccount: 3, ttlMs: 3_600_000 }, + }, + "wizard.start": { + sessionId: "channel-standard-proof", + ...step("account", "select", { + message: "Choose Telegram account", + initialValue: "personal", + options: ["personal", "work"].map((value) => ({ + value, + label: value === "work" ? "Work bot" : "Personal bot", + })), + }), + }, + "wizard.next": { + sequence: [ + step("token", "text", { message: "Telegram bot token", sensitive: true }), + step("features", "multiselect", { + initialValue: ["alpha"], + options: ["alpha", "beta"].map((value) => ({ + value, + label: value === "alpha" ? "Alpha" : "Beta", + })), + }), + step("confirm", "confirm", { message: "Apply Telegram settings?" }), + step("progress", "progress", { executor: "gateway", message: "Finish preparation" }), + { done: true, status: "done", channels: ["telegram"], accounts: [] }, + ], + }, + }, + }); + + try { + await page.goto(`${server.baseUrl}settings/channels`); + const expectedFields: Record = { + googlechat: ["service-account", "url · https://chat.example.test"], + signal: ["https://signal.example.test"], + telegram: ["@alpha_bot", "@work_bot", "2"], + }; + for (const [channelId, label] of channelEntries) { + await page.locator(".channels-item", { hasText: label }).first().click(); + const detail = page.locator(".channels-detail"); + await expect + .poll(() => detail.locator("h2.settings-section__heading").textContent()) + .toContain(label); + await detail.getByRole("button", { name: "Probe" }).waitFor(); + for (const value of expectedFields[channelId] ?? []) { + await detail.getByText(value, { exact: true }).waitFor(); + } + if (channelId !== "telegram") { + await detail.getByRole("button", { name: "Close" }).click(); + } + } + + await page.locator(".channels-detail").getByRole("button", { name: "Run setup" }).click(); + const wizard = page.locator(".channels-wizard"); + await gateway.deferNext("wizard.next"); + await wizard.getByRole("radio", { name: "Work bot" }).click(); + await expect.poll(async () => gateway.getRequests("wizard.next")).toHaveLength(1); + await expect + .poll(() => wizard.locator("wa-radio-group").getAttribute("disabled")) + .not.toBeNull(); + await gateway.resolveDeferred("wizard.next"); + + const token = wizard.getByLabel("Telegram bot token"); + await expect.poll(() => token.getAttribute("type")).toBe("password"); + await token.fill("123456:proof-secret"); + await wizard.getByRole("button", { name: "Continue" }).click(); + const beta = wizard.getByRole("button", { name: /Beta/u }); + await expect.poll(() => beta.getAttribute("aria-pressed")).toBe("false"); + await beta.click(); + await expect.poll(() => beta.getAttribute("aria-pressed")).toBe("true"); + await wizard.getByRole("button", { name: "Continue" }).click(); + await wizard.getByRole("button", { name: "Yes" }).click(); + await wizard.getByRole("button", { name: "Continue" }).click(); + await wizard.getByRole("button", { name: "Finish" }).waitFor(); + + const answers = [ + ["account", "work"], + ["token", "123456:proof-secret"], + ["features", ["alpha", "beta"]], + ["confirm", true], + ["progress", null], + ] as const; + expect((await gateway.getRequests("wizard.next")).map(({ params }) => params)).toEqual( + answers.map(([stepId, value]) => ({ + sessionId: "channel-standard-proof", + answer: { stepId, value }, + })), + ); + } finally { + await context.close(); + } + }); }); diff --git a/ui/src/lib/channels/index.ts b/ui/src/lib/channels/index.ts index ee0a5b188267..68cf7e7f94be 100644 --- a/ui/src/lib/channels/index.ts +++ b/ui/src/lib/channels/index.ts @@ -1,6 +1,7 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; import { roleScopesAllow } from "../../../../src/shared/operator-scope-compat.ts"; import type { + ChannelAccountSnapshot, ChannelsPairingApproveResult, ChannelsPairingListResult, ChannelsStatusSnapshot, @@ -81,6 +82,15 @@ export type ChannelCapability = { dispose: () => void; }; +export function resolveChannelAccounts( + channelAccounts: ChannelsStatusSnapshot["channelAccounts"] | null | undefined, + channelId: string, +): ChannelAccountSnapshot[] { + const accounts = + channelAccounts && Object.hasOwn(channelAccounts, channelId) && channelAccounts[channelId]; + return Array.isArray(accounts) ? accounts : []; +} + export function channelSnapshotEntryIsActive( snapshot: ChannelsStatusSnapshot | null, channelId: string, @@ -88,11 +98,13 @@ export function channelSnapshotEntryIsActive( if (!snapshot) { return false; } - const status = asRecord(snapshot.channels[channelId]); + const status = asRecord( + Object.hasOwn(snapshot.channels, channelId) ? snapshot.channels[channelId] : undefined, + ); if (status?.configured === true || status?.running === true || status?.connected === true) { return true; } - return (snapshot.channelAccounts[channelId] ?? []).some( + return resolveChannelAccounts(snapshot.channelAccounts, channelId).some( (account) => account.configured === true || account.running === true || account.connected === true, ); @@ -159,9 +171,9 @@ function createInitialChannelsState(snapshot: Partial = }; } -function delay(ms: number): Promise<"timeout"> { +function delay(ms: number): Promise { return new Promise((resolve) => { - setTimeout(() => resolve("timeout"), ms); + setTimeout(resolve, ms); }); } @@ -220,14 +232,9 @@ async function loadChannels( })(); const softTimeoutMs = options.softTimeoutMs; - if (typeof softTimeoutMs === "number" && softTimeoutMs > 0) { - const outcome = await Promise.race([refresh.then(() => "done" as const), delay(softTimeoutMs)]); - if (outcome === "timeout") { - return; - } - return; - } - await refresh; + await (typeof softTimeoutMs === "number" && softTimeoutMs > 0 + ? Promise.race([refresh, delay(softTimeoutMs)]) + : refresh); } function isCurrentPairingRefresh( diff --git a/ui/src/pages/agents/memory/dreaming.test.ts b/ui/src/pages/agents/memory/dreaming.test.ts index 345a368635e1..1623d8910134 100644 --- a/ui/src/pages/agents/memory/dreaming.test.ts +++ b/ui/src/pages/agents/memory/dreaming.test.ts @@ -121,10 +121,40 @@ function getConfigPatchRawPayload(config: DreamingConfigCapability): Record ({ + sourceType: "chatgpt" as const, + totalItems: 0, + totalClusters: 0, + clusters: [], + }), + }, + { + label: "overview", + key: "wikiOverview", + method: "wiki.overview", + load: loadWikiOverview, + payload: () => ({ + totalItems: 0, + totalPages: 0, + pageCounts: { source: 0, synthesis: 0, report: 0, entity: 0, concept: 0 }, + totalClaims: 0, + totalQuestions: 0, + totalContradictions: 0, + clusters: [], + }), + }, +] as const; + describe("dreaming controller", () => { - it("loads and normalizes dreaming status from doctor.memory.status", async () => { + it("retains the authoritative dreaming status from doctor.memory.status", async () => { const { state, request } = createState(); - request.mockResolvedValue({ + const payload = { dreaming: { enabled: true, timezone: "America/Los_Angeles", @@ -223,12 +253,14 @@ describe("dreaming controller", () => { }, }, }, - }); + }; + request.mockResolvedValue(payload); await loadDreamingStatus(state); expect(request).toHaveBeenCalledWith("doctor.memory.status", {}); const status = state.dreamingStatus; + expect(status).toBe(payload.dreaming); expect(status?.enabled).toBe(true); expect(status?.shortTermCount).toBe(8); expect(status?.groundedSignalCount).toBe(5); @@ -340,35 +372,74 @@ describe("dreaming controller", () => { expect(state.dreamingStatusError).toBeNull(); }); - it("preserves unknown phase state when status omits phase metadata", async () => { - const { state, request } = createState(); - request.mockResolvedValue({ - dreaming: { - enabled: true, - shortTermCount: 1, - recallSignalCount: 0, - dailySignalCount: 0, - groundedSignalCount: 0, - totalSignalCount: 1, - phaseSignalCount: 0, - lightPhaseHitCount: 0, - remPhaseHitCount: 0, - promotedTotal: 0, - promotedToday: 0, - shortTermEntries: [], - signalEntries: [], - promotedEntries: [], - }, - }); + it.each(wikiResources)( + "keeps the newest $label response across an A-to-B-to-A agent switch", + async ({ key, method, load, payload }) => { + const { state, request } = createState(); + const firstAgentA = createDeferred(); + const agentB = createDeferred(); + const secondAgentA = createDeferred(); + state.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + features: { methods: [method] }, + }; + request + .mockImplementationOnce(async () => firstAgentA.promise) + .mockImplementationOnce(async () => agentB.promise) + .mockImplementationOnce(async () => secondAgentA.promise); - await loadDreamingStatus(state); + state.selectedAgentId = "agent-a"; + const staleA = load(state); + state.selectedAgentId = "agent-b"; + const staleB = load(state); + state.selectedAgentId = "agent-a"; + const latest = load(state); + const latestPayload = payload(); - expect(state.dreamingStatus?.enabled).toBe(true); - expect(state.dreamingStatus?.phases).toBeUndefined(); - expect(state.dreamingStatusError).toBeNull(); - }); + secondAgentA.resolve(latestPayload); + await latest; + expect(state[key]).toBe(latestPayload); - it("loads and normalizes wiki import insights", async () => { + firstAgentA.resolve(payload()); + agentB.resolve(payload()); + await Promise.all([staleA, staleB]); + + expect(state[key]).toBe(latestPayload); + expect(state.resourceRequests[key]).toBeUndefined(); + expect(request).toHaveBeenCalledTimes(3); + }, + ); + + it.each(wikiResources)( + "invalidates an in-flight $label request when its gateway capability disappears", + async ({ key, method, load, payload }) => { + const { state, request } = createState(); + const deferred = createDeferred(); + state.hello = { + type: "hello-ok", + protocol: 4, + auth: { role: "operator", scopes: [] }, + features: { methods: [method] }, + }; + request.mockImplementationOnce(async () => deferred.promise); + + const stale = load(state); + state.hello = { ...state.hello, features: { methods: [] } }; + await load(state); + expect(state[key]).toBeNull(); + + deferred.resolve(payload()); + await stale; + + expect(state[key]).toBeNull(); + expect(state.resourceRequests[key]).toBeUndefined(); + expect(request).toHaveBeenCalledTimes(1); + }, + ); + + it("loads authoritative wiki import insights", async () => { const { state, request } = createState(); state.hello = { type: "hello-ok", @@ -708,56 +779,6 @@ describe("dreaming controller", () => { expect(state.wikiOverviewError).toBeNull(); }); - it("derives legacy wiki wiki overview page counts from clusters", async () => { - const { state, request } = createState(); - state.hello = { - type: "hello-ok", - protocol: 4, - auth: { role: "operator", scopes: [] }, - features: { methods: ["wiki.overview"] }, - }; - state.configSnapshot = { - hash: "hash-1", - config: { - plugins: { - entries: { - "memory-wiki": { - enabled: true, - }, - }, - }, - }, - }; - request.mockResolvedValue({ - totalItems: 1, - totalClaims: 2, - totalQuestions: 1, - totalContradictions: 0, - clusters: [ - { - key: "synthesis", - label: "Syntheses", - itemCount: 1, - claimCount: 2, - questionCount: 1, - contradictionCount: 0, - items: [], - }, - ], - }); - - await loadWikiOverview(state); - - expect(state.wikiOverview?.totalPages).toBe(1); - expect(state.wikiOverview?.pageCounts).toEqual({ - synthesis: 1, - entity: 0, - concept: 0, - source: 0, - report: 0, - }); - }); - it("falls back to config gating for wiki wiki overview when methods are not advertised", async () => { const { state, request } = createState(); state.configSnapshot = { @@ -773,8 +794,10 @@ describe("dreaming controller", () => { }, }; request.mockResolvedValue({ - totalItems: 1, - totalClaims: 2, + totalItems: 0, + totalPages: 0, + pageCounts: { synthesis: 0, entity: 0, concept: 0, source: 0, report: 0 }, + totalClaims: 0, totalQuestions: 0, totalContradictions: 0, clusters: [], @@ -783,8 +806,8 @@ describe("dreaming controller", () => { await loadWikiOverview(state); expect(request).toHaveBeenCalledWith("wiki.overview", {}); - expect(state.wikiOverview?.totalItems).toBe(1); - expect(state.wikiOverview?.totalPages).toBe(1); + expect(state.wikiOverview?.totalItems).toBe(0); + expect(state.wikiOverview?.totalPages).toBe(0); expect(state.wikiOverview?.pageCounts).toEqual({ synthesis: 0, entity: 0, @@ -792,7 +815,7 @@ describe("dreaming controller", () => { source: 0, report: 0, }); - expect(state.wikiOverview?.totalClaims).toBe(2); + expect(state.wikiOverview?.totalClaims).toBe(0); expect(state.wikiOverviewError).toBeNull(); expect(state.wikiOverviewLoading).toBe(false); }); diff --git a/ui/src/pages/agents/memory/dreaming.ts b/ui/src/pages/agents/memory/dreaming.ts index 7aad0d23bd5c..1ae2c6ff852f 100644 --- a/ui/src/pages/agents/memory/dreaming.ts +++ b/ui/src/pages/agents/memory/dreaming.ts @@ -1,4 +1,9 @@ import { asNullableRecord as asRecord } from "@openclaw/normalization-core/record-coerce"; +import type { + DoctorMemoryDreamActionPayload, + DoctorMemoryDreamDiaryPayload, + DoctorMemoryStatusPayload, +} from "../../../../../src/gateway/server-methods/doctor.ts"; import { defaultSlotIdForKey, resolveSlotSelection } from "../../../../../src/plugins/slots.ts"; import type { GatewayBrowserClient, GatewayHelloOk } from "../../../api/gateway.ts"; import type { ConfigSnapshot } from "../../../api/types.ts"; @@ -8,83 +13,10 @@ import type { RuntimeConfigCapability } from "../../../lib/config/index.ts"; import { isGatewayMethodAdvertised } from "../../../lib/gateway-methods.ts"; import { isPluginEnabledInConfigSnapshot } from "../../../lib/plugin-activation.ts"; -const DEFAULT_DREAM_DIARY_PATH = "DREAMS.md"; const MEMORY_WIKI_PLUGIN_ID = "memory-wiki"; -type DreamingPhaseStatusBase = { - enabled: boolean; - cron: string; - managedCronPresent: boolean; - nextRunAtMs?: number; -}; - -type LightDreamingStatus = DreamingPhaseStatusBase & { - lookbackDays: number; - limit: number; -}; - -type DeepDreamingStatus = DreamingPhaseStatusBase & { - limit: number; - minScore: number; - minRecallCount: number; - minUniqueQueries: number; - recencyHalfLifeDays: number; - maxAgeDays?: number; - maxPromotedSnippetTokens?: number; -}; - -type RemDreamingStatus = DreamingPhaseStatusBase & { - lookbackDays: number; - limit: number; - minPatternStrength: number; -}; - -export type DreamingEntry = { - key: string; - path: string; - startLine: number; - endLine: number; - snippet: string; - recallCount: number; - dailyCount: number; - groundedCount: number; - totalSignalCount: number; - lightHits: number; - remHits: number; - phaseHitCount: number; - promotedAt?: string; - lastRecalledAt?: string; -}; - -type DreamingStatus = { - enabled: boolean; - timezone?: string; - verboseLogging: boolean; - storageMode: "inline" | "separate" | "both"; - separateReports: boolean; - shortTermCount: number; - recallSignalCount: number; - dailySignalCount: number; - groundedSignalCount: number; - totalSignalCount: number; - phaseSignalCount: number; - lightPhaseHitCount: number; - remPhaseHitCount: number; - promotedTotal: number; - promotedToday: number; - storePath?: string; - phaseSignalPath?: string; - storeError?: string; - phaseSignalError?: string; - shortTermEntries: DreamingEntry[]; - signalEntries: DreamingEntry[]; - promotedEntries: DreamingEntry[]; - phases?: { - light: LightDreamingStatus; - deep: DeepDreamingStatus; - rem: RemDreamingStatus; - }; -}; +type DreamingStatus = NonNullable; +export type DreamingEntry = DreamingStatus["shortTermEntries"][number]; type WikiImportInsightItem = { pagePath: string; @@ -166,48 +98,8 @@ export type WikiOverview = { clusters: WikiOverviewCluster[]; }; -type DoctorMemoryStatusPayload = { - dreaming?: unknown; -}; - -type DoctorMemoryDreamDiaryPayload = { - found?: unknown; - path?: unknown; - content?: unknown; -}; - -type DoctorMemoryDreamActionPayload = { - action?: unknown; - removedEntries?: unknown; - dedupedEntries?: unknown; - keptEntries?: unknown; - written?: unknown; - replaced?: unknown; - removedShortTermEntries?: unknown; - changed?: unknown; - archiveDir?: unknown; - archivedSessionCorpus?: unknown; - archivedSessionIngestion?: unknown; - archivedDreamsDiary?: unknown; - warnings?: unknown; -}; - -type WikiImportInsightsPayload = { - sourceType?: unknown; - totalItems?: unknown; - totalClusters?: unknown; - clusters?: unknown; -}; - -type WikiOverviewPayload = { - totalItems?: unknown; - totalPages?: unknown; - pageCounts?: unknown; - totalClaims?: unknown; - totalQuestions?: unknown; - totalContradictions?: unknown; - clusters?: unknown; -}; +type DreamingResourceKey = "dreamingStatus" | "dreamDiary" | "wikiImportInsights" | "wikiOverview"; +type DreamingResourceRequest = { agentId: string | null }; export type DreamingState = { client: GatewayBrowserClient | null; @@ -216,17 +108,12 @@ export type DreamingState = { configSnapshot: ConfigSnapshot | null; applySessionKey: string; selectedAgentId: string | null; - dreamingStatusRequestAgentId?: string | null; - dreamingStatusRequestGeneration?: number; - dreamingStatusActiveRequestGeneration?: number | null; + resourceRequests: Partial>; dreamingStatusAgentId?: string | null; dreamingStatusLoading: boolean; dreamingStatusError: string | null; dreamingStatus: DreamingStatus | null; dreamingModeSaving: boolean; - dreamDiaryRequestAgentId?: string | null; - dreamDiaryRequestGeneration?: number; - dreamDiaryActiveRequestGeneration?: number | null; dreamDiaryAgentId?: string | null; dreamDiaryLoading: boolean; dreamDiaryActionLoading: boolean; @@ -235,18 +122,10 @@ export type DreamingState = { dreamDiaryError: string | null; dreamDiaryPath: string | null; dreamDiaryContent: string | null; - // Agent switches can overlap RPCs; generations keep an old A -> B -> A response - // from replacing the current agent's wiki data. - wikiImportInsightsRequestAgentId?: string | null; - wikiImportInsightsRequestGeneration?: number; - wikiImportInsightsActiveRequestGeneration?: number | null; wikiImportInsightsAgentId?: string | null; wikiImportInsightsLoading: boolean; wikiImportInsightsError: string | null; wikiImportInsights: WikiImportInsights | null; - wikiOverviewRequestAgentId?: string | null; - wikiOverviewRequestGeneration?: number; - wikiOverviewActiveRequestGeneration?: number | null; wikiOverviewAgentId?: string | null; wikiOverviewLoading: boolean; wikiOverviewError: string | null; @@ -269,6 +148,7 @@ export function createDreamingState( configSnapshot: initial.configSnapshot ?? null, applySessionKey: initial.applySessionKey ?? "main", selectedAgentId: initial.selectedAgentId ?? null, + resourceRequests: {}, dreamingStatusLoading: false, dreamingStatusError: null, dreamingStatus: null, @@ -415,47 +295,6 @@ function buildSelectedAgentPayload( return buildSelectedAgentPayloadForAgentId(resolveSelectedAgentId(state)); } -function normalizeBoolean(value: unknown, fallback = false): boolean { - return typeof value === "boolean" ? value : fallback; -} - -function normalizeFiniteInt(value: unknown, fallback = 0): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return fallback; - } - return Math.max(0, Math.floor(value)); -} - -function normalizeFiniteScore(value: unknown, fallback = 0): number { - if (typeof value !== "number" || !Number.isFinite(value)) { - return fallback; - } - return Math.max(0, Math.min(1, value)); -} - -function normalizeStorageMode(value: unknown): DreamingStatus["storageMode"] { - const normalized = normalizeTrimmedString(value)?.toLowerCase(); - if (normalized === "inline" || normalized === "separate" || normalized === "both") { - return normalized; - } - return "inline"; -} - -function normalizeNextRun(value: unknown): number | undefined { - return typeof value === "number" && Number.isFinite(value) ? value : undefined; -} - -function normalizePhaseStatusBase(record: Record | null): DreamingPhaseStatusBase { - return { - enabled: normalizeBoolean(record?.enabled, false), - cron: normalizeTrimmedString(record?.cron) ?? "", - managedCronPresent: normalizeBoolean(record?.managedCronPresent, false), - ...(normalizeNextRun(record?.nextRunAtMs) !== undefined - ? { nextRunAtMs: normalizeNextRun(record?.nextRunAtMs) } - : {}), - }; -} - export function resolveConfiguredDreaming(configValue: Record | null): { pluginId: string; enabled: boolean; @@ -474,608 +313,142 @@ export function resolveConfiguredDreaming(configValue: Record | const overridden = typeof dreaming?.enabled === "boolean"; return { pluginId, - enabled: slotSelection.kind === "off" ? false : normalizeBoolean(dreaming?.enabled, true), + enabled: slotSelection.kind !== "off" && dreaming?.enabled !== false, overridden, engineOff: slotSelection.kind === "off", }; } -function normalizeDreamingEntry(raw: unknown): DreamingEntry | null { - const record = asRecord(raw); - const key = normalizeTrimmedString(record?.key); - const path = normalizeTrimmedString(record?.path); - const snippet = normalizeTrimmedString(record?.snippet); - if (!key || !path || !snippet) { - return null; - } - const promotedAt = normalizeTrimmedString(record?.promotedAt); - const lastRecalledAt = normalizeTrimmedString(record?.lastRecalledAt); - return { - key, - path, - startLine: Math.max(1, normalizeFiniteInt(record?.startLine, 1)), - endLine: Math.max(1, normalizeFiniteInt(record?.endLine, 1)), - snippet, - recallCount: normalizeFiniteInt(record?.recallCount, 0), - dailyCount: normalizeFiniteInt(record?.dailyCount, 0), - groundedCount: normalizeFiniteInt(record?.groundedCount, 0), - totalSignalCount: normalizeFiniteInt(record?.totalSignalCount, 0), - lightHits: normalizeFiniteInt(record?.lightHits, 0), - remHits: normalizeFiniteInt(record?.remHits, 0), - phaseHitCount: normalizeFiniteInt(record?.phaseHitCount, 0), - ...(promotedAt ? { promotedAt } : {}), - ...(lastRecalledAt ? { lastRecalledAt } : {}), - }; -} +type DreamingResourcePayloads = { + dreamingStatus: DoctorMemoryStatusPayload; + dreamDiary: DoctorMemoryDreamDiaryPayload; + wikiImportInsights: WikiImportInsights; + wikiOverview: WikiOverview; +}; -function normalizeDreamingEntries(raw: unknown): DreamingEntry[] { - if (!Array.isArray(raw)) { - return []; - } - return raw - .map((entry) => normalizeDreamingEntry(entry)) - .filter((entry): entry is DreamingEntry => entry !== null); -} +type DreamingResourceSpec = { + method: string; + clear: (state: DreamingState) => void; + apply: (state: DreamingState, payload: DreamingResourcePayloads[Key]) => void; +}; -function normalizeStringArray(raw: unknown): string[] { - if (!Array.isArray(raw)) { - return []; - } - return raw.filter( - (entry): entry is string => typeof entry === "string" && entry.trim().length > 0, - ); -} +const DREAMING_RESOURCE_SPECS: { + [Key in DreamingResourceKey]: DreamingResourceSpec; +} = { + dreamingStatus: { + method: "doctor.memory.status", + clear: (state) => { + state.dreamingStatus = null; + }, + apply: (state, payload) => { + state.dreamingStatus = payload.dreaming ?? null; + }, + }, + dreamDiary: { + method: "doctor.memory.dreamDiary", + clear: (state) => { + state.dreamDiaryPath = null; + state.dreamDiaryContent = null; + }, + apply: (state, payload) => { + state.dreamDiaryPath = payload.path; + state.dreamDiaryContent = payload.found ? (payload.content ?? "") : null; + }, + }, + wikiImportInsights: { + method: "wiki.importInsights", + clear: (state) => { + state.wikiImportInsights = null; + }, + apply: (state, payload) => { + state.wikiImportInsights = payload; + }, + }, + wikiOverview: { + method: "wiki.overview", + clear: (state) => { + state.wikiOverview = null; + }, + apply: (state, payload) => { + state.wikiOverview = payload; + }, + }, +}; -function normalizeWikiImportInsightItem(raw: unknown): WikiImportInsightItem | null { - const record = asRecord(raw); - const pagePath = normalizeTrimmedString(record?.pagePath); - const title = normalizeTrimmedString(record?.title); - const riskLevel = normalizeTrimmedString(record?.riskLevel); - const topicKey = normalizeTrimmedString(record?.topicKey); - const topicLabel = normalizeTrimmedString(record?.topicLabel); - const digestStatus = normalizeTrimmedString(record?.digestStatus); - const summary = normalizeTrimmedString(record?.summary); +async function loadDreamingResource( + state: DreamingState, + key: Key, + spec: DreamingResourceSpec = DREAMING_RESOURCE_SPECS[key], +): Promise { + const client = state.client; + if (!client || !state.connected) { + return; + } + + const agentId = resolveSelectedAgentId(state); + const loadingKey = `${key}Loading` as const; + const errorKey = `${key}Error` as const; + const agentKey = `${key}AgentId` as const; + if (state[agentKey] !== agentId) { + spec.clear(state); + } if ( - !pagePath || - !title || - !topicKey || - !topicLabel || - !summary || - (riskLevel !== "low" && - riskLevel !== "medium" && - riskLevel !== "high" && - riskLevel !== "unknown") || - (digestStatus !== "available" && digestStatus !== "withheld") + (key === "wikiImportInsights" || key === "wikiOverview") && + !canCallMemoryWikiMethod(state, spec.method) ) { - return null; + delete state.resourceRequests[key]; + state[loadingKey] = false; + state[errorKey] = null; + spec.clear(state); + return; } - return { - pagePath, - title, - riskLevel, - riskReasons: normalizeStringArray(record?.riskReasons), - labels: normalizeStringArray(record?.labels), - topicKey, - topicLabel, - digestStatus, - activeBranchMessages: normalizeFiniteInt(record?.activeBranchMessages, 0), - userMessageCount: normalizeFiniteInt(record?.userMessageCount, 0), - assistantMessageCount: normalizeFiniteInt(record?.assistantMessageCount, 0), - ...(normalizeTrimmedString(record?.firstUserLine) - ? { firstUserLine: normalizeTrimmedString(record?.firstUserLine) } - : {}), - ...(normalizeTrimmedString(record?.lastUserLine) - ? { lastUserLine: normalizeTrimmedString(record?.lastUserLine) } - : {}), - ...(normalizeTrimmedString(record?.assistantOpener) - ? { assistantOpener: normalizeTrimmedString(record?.assistantOpener) } - : {}), - summary, - candidateSignals: normalizeStringArray(record?.candidateSignals), - correctionSignals: normalizeStringArray(record?.correctionSignals), - preferenceSignals: normalizeStringArray(record?.preferenceSignals), - ...(normalizeTrimmedString(record?.createdAt) - ? { createdAt: normalizeTrimmedString(record?.createdAt) } - : {}), - ...(normalizeTrimmedString(record?.updatedAt) - ? { updatedAt: normalizeTrimmedString(record?.updatedAt) } - : {}), - }; -} -function normalizeWikiImportInsightCluster(raw: unknown): WikiImportInsightCluster | null { - const record = asRecord(raw); - const key = normalizeTrimmedString(record?.key); - const label = normalizeTrimmedString(record?.label); - if (!key || !label) { - return null; + const active = state.resourceRequests[key]; + if (active?.agentId === agentId && state[loadingKey]) { + return; } - const items = Array.isArray(record?.items) - ? record.items - .map((entry) => normalizeWikiImportInsightItem(entry)) - .filter((entry): entry is WikiImportInsightItem => entry !== null) - : []; - return { - key, - label, - itemCount: normalizeFiniteInt(record?.itemCount, items.length), - highRiskCount: normalizeFiniteInt( - record?.highRiskCount, - items.filter((entry) => entry.riskLevel === "high").length, - ), - withheldCount: normalizeFiniteInt( - record?.withheldCount, - items.filter((entry) => entry.digestStatus === "withheld").length, - ), - preferenceSignalCount: normalizeFiniteInt( - record?.preferenceSignalCount, - items.reduce((sum, entry) => sum + entry.preferenceSignals.length, 0), - ), - ...(normalizeTrimmedString(record?.updatedAt) - ? { updatedAt: normalizeTrimmedString(record?.updatedAt) } - : {}), - items, - }; -} -function normalizeWikiImportInsights(raw: unknown): WikiImportInsights { - const record = asRecord(raw); - const clusters = Array.isArray(record?.clusters) - ? record.clusters - .map((entry) => normalizeWikiImportInsightCluster(entry)) - .filter((entry): entry is WikiImportInsightCluster => entry !== null) - : []; - return { - sourceType: record?.sourceType === "chatgpt" ? "chatgpt" : "chatgpt", - totalItems: normalizeFiniteInt( - record?.totalItems, - clusters.reduce((sum, cluster) => sum + cluster.itemCount, 0), - ), - totalClusters: normalizeFiniteInt(record?.totalClusters, clusters.length), - clusters, - }; -} - -function normalizeWikiPageKind(value: unknown): WikiOverviewItem["kind"] | undefined { - return value === "entity" || - value === "concept" || - value === "source" || - value === "synthesis" || - value === "report" - ? value - : undefined; -} - -function createEmptyWikiOverviewPageCounts(): WikiOverviewPageCounts { - return { - synthesis: 0, - entity: 0, - concept: 0, - source: 0, - report: 0, - }; -} - -function normalizeWikiOverviewPageCounts( - raw: unknown, - fallback: WikiOverviewPageCounts, -): WikiOverviewPageCounts { - const record = asRecord(raw); - return { - synthesis: normalizeFiniteInt(record?.synthesis, fallback.synthesis), - entity: normalizeFiniteInt(record?.entity, fallback.entity), - concept: normalizeFiniteInt(record?.concept, fallback.concept), - source: normalizeFiniteInt(record?.source, fallback.source), - report: normalizeFiniteInt(record?.report, fallback.report), - }; -} - -function sumWikiOverviewPageCounts(pageCounts: WikiOverviewPageCounts): number { - return ( - pageCounts.synthesis + - pageCounts.entity + - pageCounts.concept + - pageCounts.source + - pageCounts.report - ); -} - -function normalizeWikiOverviewItem(raw: unknown): WikiOverviewItem | null { - const record = asRecord(raw); - const pagePath = normalizeTrimmedString(record?.pagePath); - const title = normalizeTrimmedString(record?.title); - const kind = normalizeWikiPageKind(record?.kind); - if (!pagePath || !title || !kind) { - return null; + // Request identity, not agent identity, rejects stale A -> B -> A completions. + const request: DreamingResourceRequest = { agentId }; + state.resourceRequests[key] = request; + state[loadingKey] = true; + state[errorKey] = null; + try { + const payload = await client.request( + spec.method, + buildSelectedAgentPayloadForAgentId(agentId), + ); + if (state.resourceRequests[key] !== request || resolveSelectedAgentId(state) !== agentId) { + return; + } + spec.apply(state, payload); + state[agentKey] = agentId; + } catch (error) { + if (state.resourceRequests[key] === request && resolveSelectedAgentId(state) === agentId) { + state[errorKey] = String(error); + } + } finally { + if (state.resourceRequests[key] === request) { + delete state.resourceRequests[key]; + state[loadingKey] = false; + } } - return { - pagePath, - title, - kind, - ...(normalizeTrimmedString(record?.id) ? { id: normalizeTrimmedString(record?.id) } : {}), - ...(normalizeTrimmedString(record?.updatedAt) - ? { updatedAt: normalizeTrimmedString(record?.updatedAt) } - : {}), - ...(normalizeTrimmedString(record?.sourceType) - ? { sourceType: normalizeTrimmedString(record?.sourceType) } - : {}), - claimCount: normalizeFiniteInt(record?.claimCount, 0), - questionCount: normalizeFiniteInt(record?.questionCount, 0), - contradictionCount: normalizeFiniteInt(record?.contradictionCount, 0), - claims: normalizeStringArray(record?.claims), - questions: normalizeStringArray(record?.questions), - contradictions: normalizeStringArray(record?.contradictions), - ...(normalizeTrimmedString(record?.snippet) - ? { snippet: normalizeTrimmedString(record?.snippet) } - : {}), - }; -} - -function normalizeWikiOverviewCluster(raw: unknown): WikiOverviewCluster | null { - const record = asRecord(raw); - const key = normalizeWikiPageKind(record?.key); - const label = normalizeTrimmedString(record?.label); - if (!key || !label) { - return null; - } - const items = Array.isArray(record?.items) - ? record.items - .map((entry) => normalizeWikiOverviewItem(entry)) - .filter((entry): entry is WikiOverviewItem => entry !== null) - : []; - return { - key, - label, - itemCount: normalizeFiniteInt(record?.itemCount, items.length), - claimCount: normalizeFiniteInt( - record?.claimCount, - items.reduce((sum, item) => sum + item.claimCount, 0), - ), - questionCount: normalizeFiniteInt( - record?.questionCount, - items.reduce((sum, item) => sum + item.questionCount, 0), - ), - contradictionCount: normalizeFiniteInt( - record?.contradictionCount, - items.reduce((sum, item) => sum + item.contradictionCount, 0), - ), - ...(normalizeTrimmedString(record?.updatedAt) - ? { updatedAt: normalizeTrimmedString(record?.updatedAt) } - : {}), - items, - }; -} - -function normalizeWikiOverview(raw: unknown): WikiOverview { - const record = asRecord(raw); - const clusters = Array.isArray(record?.clusters) - ? record.clusters - .map((entry) => normalizeWikiOverviewCluster(entry)) - .filter((entry): entry is WikiOverviewCluster => entry !== null) - : []; - const totalItems = normalizeFiniteInt( - record?.totalItems, - clusters.reduce((sum, cluster) => sum + cluster.itemCount, 0), - ); - const fallbackPageCounts = createEmptyWikiOverviewPageCounts(); - for (const cluster of clusters) { - fallbackPageCounts[cluster.key] += cluster.itemCount; - } - const pageCounts = normalizeWikiOverviewPageCounts(record?.pageCounts, fallbackPageCounts); - const fallbackTotalPages = sumWikiOverviewPageCounts(pageCounts) || totalItems; - return { - totalItems, - totalPages: normalizeFiniteInt(record?.totalPages, fallbackTotalPages), - pageCounts, - totalClaims: normalizeFiniteInt( - record?.totalClaims, - clusters.reduce((sum, cluster) => sum + cluster.claimCount, 0), - ), - totalQuestions: normalizeFiniteInt( - record?.totalQuestions, - clusters.reduce((sum, cluster) => sum + cluster.questionCount, 0), - ), - totalContradictions: normalizeFiniteInt( - record?.totalContradictions, - clusters.reduce((sum, cluster) => sum + cluster.contradictionCount, 0), - ), - clusters, - }; -} - -function normalizeDreamingStatus(raw: unknown): DreamingStatus | null { - const record = asRecord(raw); - if (!record) { - return null; - } - const phasesRecord = asRecord(record.phases); - const lightRecord = asRecord(phasesRecord?.light); - const deepRecord = asRecord(phasesRecord?.deep); - const remRecord = asRecord(phasesRecord?.rem); - const phases = - lightRecord && deepRecord && remRecord - ? { - light: { - ...normalizePhaseStatusBase(lightRecord), - lookbackDays: normalizeFiniteInt(lightRecord.lookbackDays, 0), - limit: normalizeFiniteInt(lightRecord.limit, 0), - }, - deep: { - ...normalizePhaseStatusBase(deepRecord), - limit: normalizeFiniteInt(deepRecord.limit, 0), - minScore: normalizeFiniteScore(deepRecord.minScore, 0), - minRecallCount: normalizeFiniteInt(deepRecord.minRecallCount, 0), - minUniqueQueries: normalizeFiniteInt(deepRecord.minUniqueQueries, 0), - recencyHalfLifeDays: normalizeFiniteInt(deepRecord.recencyHalfLifeDays, 0), - ...(typeof deepRecord.maxAgeDays === "number" && Number.isFinite(deepRecord.maxAgeDays) - ? { maxAgeDays: normalizeFiniteInt(deepRecord.maxAgeDays, 0) } - : {}), - ...(typeof deepRecord.maxPromotedSnippetTokens === "number" && - Number.isFinite(deepRecord.maxPromotedSnippetTokens) - ? { - maxPromotedSnippetTokens: normalizeFiniteInt( - deepRecord.maxPromotedSnippetTokens, - 0, - ), - } - : {}), - }, - rem: { - ...normalizePhaseStatusBase(remRecord), - lookbackDays: normalizeFiniteInt(remRecord.lookbackDays, 0), - limit: normalizeFiniteInt(remRecord.limit, 0), - minPatternStrength: normalizeFiniteScore(remRecord.minPatternStrength, 0), - }, - } - : undefined; - const timezone = normalizeTrimmedString(record.timezone); - const storePath = normalizeTrimmedString(record.storePath); - const phaseSignalPath = normalizeTrimmedString(record.phaseSignalPath); - const storeError = normalizeTrimmedString(record.storeError); - const phaseSignalError = normalizeTrimmedString(record.phaseSignalError); - - return { - enabled: normalizeBoolean(record.enabled, false), - ...(timezone ? { timezone } : {}), - verboseLogging: normalizeBoolean(record.verboseLogging, false), - storageMode: normalizeStorageMode(record.storageMode), - separateReports: normalizeBoolean(record.separateReports, false), - shortTermCount: normalizeFiniteInt(record.shortTermCount, 0), - recallSignalCount: normalizeFiniteInt(record.recallSignalCount, 0), - dailySignalCount: normalizeFiniteInt(record.dailySignalCount, 0), - groundedSignalCount: normalizeFiniteInt(record.groundedSignalCount, 0), - totalSignalCount: normalizeFiniteInt(record.totalSignalCount, 0), - phaseSignalCount: normalizeFiniteInt(record.phaseSignalCount, 0), - lightPhaseHitCount: normalizeFiniteInt(record.lightPhaseHitCount, 0), - remPhaseHitCount: normalizeFiniteInt(record.remPhaseHitCount, 0), - promotedTotal: normalizeFiniteInt(record.promotedTotal, 0), - promotedToday: normalizeFiniteInt(record.promotedToday, 0), - ...(storePath ? { storePath } : {}), - ...(phaseSignalPath ? { phaseSignalPath } : {}), - ...(storeError ? { storeError } : {}), - ...(phaseSignalError ? { phaseSignalError } : {}), - shortTermEntries: normalizeDreamingEntries(record.shortTermEntries), - signalEntries: normalizeDreamingEntries(record.signalEntries), - promotedEntries: normalizeDreamingEntries(record.promotedEntries), - ...(phases ? { phases } : {}), - }; } export async function loadDreamingStatus(state: DreamingState): Promise { - if (!state.client || !state.connected) { - return; - } - const agentId = resolveSelectedAgentId(state); - if (state.dreamingStatusLoading && state.dreamingStatusRequestAgentId === agentId) { - return; - } - if (state.dreamingStatusAgentId !== agentId) { - state.dreamingStatus = null; - } - const requestGeneration = (state.dreamingStatusRequestGeneration ?? 0) + 1; - state.dreamingStatusRequestGeneration = requestGeneration; - state.dreamingStatusActiveRequestGeneration = requestGeneration; - state.dreamingStatusRequestAgentId = agentId; - state.dreamingStatusLoading = true; - state.dreamingStatusError = null; - try { - const payload = await state.client.request( - "doctor.memory.status", - buildSelectedAgentPayloadForAgentId(agentId), - ); - if ( - state.dreamingStatusActiveRequestGeneration !== requestGeneration || - state.dreamingStatusRequestAgentId !== agentId || - resolveSelectedAgentId(state) !== agentId - ) { - return; - } - state.dreamingStatus = normalizeDreamingStatus(payload?.dreaming); - state.dreamingStatusAgentId = agentId; - } catch (err) { - if ( - state.dreamingStatusActiveRequestGeneration === requestGeneration && - state.dreamingStatusRequestAgentId === agentId && - resolveSelectedAgentId(state) === agentId - ) { - state.dreamingStatusError = String(err); - } - } finally { - if (state.dreamingStatusActiveRequestGeneration === requestGeneration) { - state.dreamingStatusLoading = false; - state.dreamingStatusRequestAgentId = null; - state.dreamingStatusActiveRequestGeneration = null; - } - } + await loadDreamingResource(state, "dreamingStatus"); } export async function loadDreamDiary(state: DreamingState): Promise { - if (!state.client || !state.connected) { - return; - } - const agentId = resolveSelectedAgentId(state); - if (state.dreamDiaryLoading && state.dreamDiaryRequestAgentId === agentId) { - return; - } - if (state.dreamDiaryAgentId !== agentId) { - state.dreamDiaryPath = null; - state.dreamDiaryContent = null; - } - const requestGeneration = (state.dreamDiaryRequestGeneration ?? 0) + 1; - state.dreamDiaryRequestGeneration = requestGeneration; - state.dreamDiaryActiveRequestGeneration = requestGeneration; - state.dreamDiaryRequestAgentId = agentId; - state.dreamDiaryLoading = true; - state.dreamDiaryError = null; - try { - const payload = await state.client.request( - "doctor.memory.dreamDiary", - buildSelectedAgentPayloadForAgentId(agentId), - ); - if ( - state.dreamDiaryActiveRequestGeneration !== requestGeneration || - state.dreamDiaryRequestAgentId !== agentId || - resolveSelectedAgentId(state) !== agentId - ) { - return; - } - const path = normalizeTrimmedString(payload?.path) ?? DEFAULT_DREAM_DIARY_PATH; - const found = payload?.found === true; - if (found) { - state.dreamDiaryPath = path; - state.dreamDiaryContent = typeof payload?.content === "string" ? payload.content : ""; - } else { - state.dreamDiaryPath = path; - state.dreamDiaryContent = null; - } - state.dreamDiaryAgentId = agentId; - } catch (err) { - if ( - state.dreamDiaryActiveRequestGeneration === requestGeneration && - state.dreamDiaryRequestAgentId === agentId && - resolveSelectedAgentId(state) === agentId - ) { - state.dreamDiaryError = String(err); - } - } finally { - if (state.dreamDiaryActiveRequestGeneration === requestGeneration) { - state.dreamDiaryLoading = false; - state.dreamDiaryRequestAgentId = null; - state.dreamDiaryActiveRequestGeneration = null; - } - } + await loadDreamingResource(state, "dreamDiary"); } export async function loadWikiImportInsights(state: DreamingState): Promise { - if (!state.client || !state.connected) { - return; - } - const agentId = resolveSelectedAgentId(state); - if (state.wikiImportInsightsLoading && state.wikiImportInsightsRequestAgentId === agentId) { - return; - } - if (state.wikiImportInsightsAgentId !== agentId) { - state.wikiImportInsights = null; - } - if (!canCallMemoryWikiMethod(state, "wiki.importInsights")) { - state.wikiImportInsightsActiveRequestGeneration = null; - state.wikiImportInsightsRequestAgentId = null; - state.wikiImportInsightsLoading = false; - state.wikiImportInsights = null; - state.wikiImportInsightsError = null; - return; - } - const requestGeneration = (state.wikiImportInsightsRequestGeneration ?? 0) + 1; - state.wikiImportInsightsRequestGeneration = requestGeneration; - state.wikiImportInsightsActiveRequestGeneration = requestGeneration; - state.wikiImportInsightsRequestAgentId = agentId; - state.wikiImportInsightsLoading = true; - state.wikiImportInsightsError = null; - try { - const payload = await state.client.request( - "wiki.importInsights", - buildSelectedAgentPayloadForAgentId(agentId), - ); - if ( - state.wikiImportInsightsActiveRequestGeneration !== requestGeneration || - state.wikiImportInsightsRequestAgentId !== agentId || - resolveSelectedAgentId(state) !== agentId - ) { - return; - } - state.wikiImportInsights = normalizeWikiImportInsights(payload); - state.wikiImportInsightsAgentId = agentId; - } catch (err) { - if ( - state.wikiImportInsightsActiveRequestGeneration === requestGeneration && - state.wikiImportInsightsRequestAgentId === agentId && - resolveSelectedAgentId(state) === agentId - ) { - state.wikiImportInsightsError = String(err); - } - } finally { - if (state.wikiImportInsightsActiveRequestGeneration === requestGeneration) { - state.wikiImportInsightsLoading = false; - state.wikiImportInsightsRequestAgentId = null; - state.wikiImportInsightsActiveRequestGeneration = null; - } - } + await loadDreamingResource(state, "wikiImportInsights"); } export async function loadWikiOverview(state: DreamingState): Promise { - if (!state.client || !state.connected) { - return; - } - const agentId = resolveSelectedAgentId(state); - if (state.wikiOverviewLoading && state.wikiOverviewRequestAgentId === agentId) { - return; - } - if (state.wikiOverviewAgentId !== agentId) { - state.wikiOverview = null; - } - if (!canCallMemoryWikiMethod(state, "wiki.overview")) { - state.wikiOverviewActiveRequestGeneration = null; - state.wikiOverviewRequestAgentId = null; - state.wikiOverviewLoading = false; - state.wikiOverview = null; - state.wikiOverviewError = null; - return; - } - const requestGeneration = (state.wikiOverviewRequestGeneration ?? 0) + 1; - state.wikiOverviewRequestGeneration = requestGeneration; - state.wikiOverviewActiveRequestGeneration = requestGeneration; - state.wikiOverviewRequestAgentId = agentId; - state.wikiOverviewLoading = true; - state.wikiOverviewError = null; - try { - const payload = await state.client.request( - "wiki.overview", - buildSelectedAgentPayloadForAgentId(agentId), - ); - if ( - state.wikiOverviewActiveRequestGeneration !== requestGeneration || - state.wikiOverviewRequestAgentId !== agentId || - resolveSelectedAgentId(state) !== agentId - ) { - return; - } - state.wikiOverview = normalizeWikiOverview(payload); - state.wikiOverviewAgentId = agentId; - } catch (err) { - if ( - state.wikiOverviewActiveRequestGeneration === requestGeneration && - state.wikiOverviewRequestAgentId === agentId && - resolveSelectedAgentId(state) === agentId - ) { - state.wikiOverviewError = String(err); - } - } finally { - if (state.wikiOverviewActiveRequestGeneration === requestGeneration) { - state.wikiOverviewLoading = false; - state.wikiOverviewRequestAgentId = null; - state.wikiOverviewActiveRequestGeneration = null; - } - } + await loadDreamingResource(state, "wikiOverview"); } async function runDreamDiaryAction( @@ -1309,4 +682,3 @@ export async function updateDreamingEnabled( } return ok; } -/* oxlint-disable max-lines -- TODO: split this grandfathered oversized file. */ diff --git a/ui/src/pages/agents/memory/memory-panel.test.ts b/ui/src/pages/agents/memory/memory-panel.test.ts index 3958ccb77020..ec997ee27d20 100644 --- a/ui/src/pages/agents/memory/memory-panel.test.ts +++ b/ui/src/pages/agents/memory/memory-panel.test.ts @@ -487,3 +487,251 @@ describe("AgentMemoryPanel gateway lifecycle", () => { expect(page.viewState.wikiPreviewContent).toBe(""); }); }); + +describe.runIf(process.env.OPENCLAW_UI_MEMORY_CHROMIUM_E2E === "1")( + "agent memory real Chromium owner proof", + () => { + let browser: import("playwright").Browser; + let server: import("../../../test-helpers/control-ui-e2e.ts").ControlUiE2eServer; + let e2e: typeof import("../../../test-helpers/control-ui-e2e.ts"); + + beforeAll(async () => { + const { chromium } = await import("playwright"); + e2e = await import("../../../test-helpers/control-ui-e2e.ts"); + const executablePath = e2e.resolvePlaywrightChromiumExecutablePath(chromium.executablePath()); + if (!e2e.canRunPlaywrightChromium(executablePath)) { + throw new Error(`Real Chromium required but unavailable: ${executablePath}`); + } + server = await e2e.startControlUiE2eServer(); + browser = await chromium.launch({ executablePath, headless: true }); + }, 90_000); + + afterAll(async () => { + await browser?.close(); + await server?.close(); + }); + + it("preserves both routes, agent ownership, wiki gating, and reconnects", async () => { + const status = (agentId: string, promotedToday: number) => ({ + agentId, + provider: "builtin", + embedding: { ok: true, checked: true }, + dreaming: { + enabled: true, + verboseLogging: false, + storageMode: "inline" as const, + separateReports: false, + shortTermCount: promotedToday, + recallSignalCount: 0, + dailySignalCount: 0, + groundedSignalCount: 0, + totalSignalCount: 0, + phaseSignalCount: 0, + lightPhaseHitCount: 0, + remPhaseHitCount: 0, + promotedTotal: promotedToday, + promotedToday, + shortTermEntries: [], + signalEntries: [], + promotedEntries: [], + phases: { + light: { + enabled: true, + cron: "0 * * * *", + managedCronPresent: true, + lookbackDays: 2, + limit: 10, + }, + deep: { + enabled: true, + cron: "0 3 * * *", + managedCronPresent: true, + limit: 10, + minScore: 0.8, + minRecallCount: 2, + minUniqueQueries: 2, + recencyHalfLifeDays: 14, + }, + rem: { + enabled: false, + cron: "0 5 * * 0", + managedCronPresent: false, + lookbackDays: 7, + limit: 10, + minPatternStrength: 0.75, + }, + }, + }, + }); + const diary = (agentId: string) => ({ + agentId, + found: true, + path: "DREAMS.md", + content: `# Dream Diary\n\n*April 5, 2026, 3:00 AM*\n\n${agentId} owns this dream.`, + }); + const config = { + agents: { entries: { main: { default: true }, support: {} } }, + plugins: { + entries: { + "memory-core": { enabled: true, config: { dreaming: { enabled: true } } }, + }, + }, + }; + const roster = { + agents: [ + { id: "main", name: "Main" }, + { id: "support", name: "Support" }, + ], + defaultId: "main", + mainKey: "main", + scope: "agent", + }; + const context = await browser.newContext({ + locale: "en-US", + serviceWorkers: "block", + viewport: { width: 1440, height: 900 }, + }); + const page = await context.newPage(); + const gateway = await e2e.installMockGateway(page, { + featureMethods: [ + "chat.metadata", + "chat.startup", + "doctor.memory.status", + "doctor.memory.dreamDiary", + ], + methodResponses: { + "agents.list": roster, + "config.get": { + config, + sourceConfig: config, + runtimeConfig: config, + hash: "memory-proof-1", + issues: [], + raw: JSON.stringify(config), + valid: true, + }, + "plugins.list": { + plugins: [ + { + id: "memory-core", + name: "OpenClaw Memory", + installed: true, + enabled: true, + state: "enabled", + kind: ["memory"], + }, + ], + diagnostics: [], + mutationAllowed: true, + }, + "doctor.memory.status": { + cases: [ + { match: { agentId: "main" }, response: status("main", 11) }, + { match: { agentId: "support" }, response: status("support", 22) }, + ], + }, + "doctor.memory.dreamDiary": { + cases: [ + { match: { agentId: "main" }, response: diary("main") }, + { match: { agentId: "support" }, response: diary("support") }, + ], + }, + }, + }); + const detail = () => page.locator("openclaw-agent-memory-panel .dreams__status-detail"); + const requestCount = () => + gateway.getRequests("doctor.memory.status").then((requests) => requests.length); + const chooseAgent = async (name: string) => { + const picker = page.locator(".memory-page .agent-scope-control openclaw-agent-select"); + await picker.locator(".agent-select__trigger").click(); + await picker + .locator("wa-dropdown-item[data-agent-option]") + .filter({ hasText: name }) + .evaluate((item) => (item as HTMLElement).click()); + }; + + try { + expect((await page.goto(`${server.baseUrl}settings/agents/main/memory`))?.status()).toBe( + 200, + ); + await e2e.waitForControlUiRoute(page, { + routeId: "agents", + pathname: "/settings/agents/main/memory", + }); + await expect + .poll(async () => await detail().textContent(), { timeout: 15_000 }) + .toContain("11 promoted"); + expect(await gateway.getRequests("wiki.importInsights")).toHaveLength(0); + expect(await gateway.getRequests("wiki.overview")).toHaveLength(0); + + const beforeFirstMain = await requestCount(); + await gateway.deferNext("doctor.memory.status"); + await page.evaluate(() => { + history.pushState(null, "", "/settings/memory/dreams"); + window.dispatchEvent(new PopStateEvent("popstate")); + }); + await e2e.waitForControlUiRoute(page, { + routeId: "memory", + pathname: "/settings/memory/dreams", + }); + await expect.poll(requestCount, { timeout: 15_000 }).toBeGreaterThan(beforeFirstMain); + + const beforeSupport = await requestCount(); + await gateway.deferNext("doctor.memory.status"); + await chooseAgent("Support"); + await expect.poll(requestCount, { timeout: 15_000 }).toBeGreaterThan(beforeSupport); + await gateway.setMethodResponse("doctor.memory.status", { + cases: [ + { match: { agentId: "main" }, response: status("main", 33) }, + { match: { agentId: "support" }, response: status("support", 22) }, + ], + }); + await chooseAgent("Main"); + await expect + .poll(async () => await detail().textContent(), { timeout: 15_000 }) + .toContain("33 promoted"); + await gateway.resolveDeferred("doctor.memory.status", status("main", 11)); + await gateway.resolveDeferred("doctor.memory.status", status("support", 22)); + await expect + .poll(async () => await detail().textContent(), { timeout: 15_000 }) + .toContain("33 promoted"); + expect(await gateway.getRequests("wiki.importInsights")).toHaveLength(0); + expect(await gateway.getRequests("wiki.overview")).toHaveLength(0); + + await gateway.setMethodResponse("doctor.memory.status", status("main", 44)); + const beforeReconnect = await requestCount(); + const socketCount = await gateway.getSocketCount(); + await gateway.closeLatest(1001, "proxy idle timeout"); + await gateway.setOnline(false); + await expect + .poll( + () => + page.evaluate( + () => + ( + document.querySelector("openclaw-app") as HTMLElement & { + runtime?: { context: { gateway: { snapshot: { phase: string } } } }; + } + ).runtime?.context.gateway.snapshot.phase, + ), + { timeout: 15_000 }, + ) + .toBe("reconnecting"); + await expect + .poll(() => gateway.getSocketCount(), { timeout: 15_000 }) + .toBeGreaterThan(socketCount); + await gateway.setOnline(true); + await expect.poll(requestCount, { timeout: 15_000 }).toBeGreaterThan(beforeReconnect); + await expect + .poll(async () => await detail().textContent(), { timeout: 15_000 }) + .toContain("44 promoted"); + await e2e.waitForControlUiRoute(page, { + routeId: "memory", + pathname: "/settings/memory/dreams", + }); + } finally { + await context.close(); + } + }, 120_000); + }, +); diff --git a/ui/src/pages/agents/memory/memory-panel.ts b/ui/src/pages/agents/memory/memory-panel.ts index 78f244f73095..ac316cd3c695 100644 --- a/ui/src/pages/agents/memory/memory-panel.ts +++ b/ui/src/pages/agents/memory/memory-panel.ts @@ -31,7 +31,12 @@ import { type DreamingState, } from "./dreaming.ts"; import { renderDreamingToggleConfirmation } from "./toggle-confirmation.ts"; -import { createDreamingViewState, renderDreaming, type DreamingViewState } from "./view.ts"; +import { + createDreamingViewState, + renderDreaming, + resetWikiPreview, + type DreamingViewState, +} from "./view.ts"; type WikiPagePreview = { title: string; @@ -187,25 +192,12 @@ class AgentMemoryPanel extends OpenClawLightDomElement { } private resetTransientState() { - this.resetWikiPreview(); + resetWikiPreview(this.viewState); this.toggleConfirmOpen = false; this.toggleConfirmLoading = false; this.pendingEnabled = null; } - private resetWikiPreview() { - this.viewState.wikiPreviewRequestId += 1; - this.viewState.wikiPreviewOpen = false; - this.viewState.wikiPreviewLoading = false; - this.viewState.wikiPreviewTitle = ""; - this.viewState.wikiPreviewPath = ""; - this.viewState.wikiPreviewUpdatedAt = null; - this.viewState.wikiPreviewContent = ""; - this.viewState.wikiPreviewTotalLines = null; - this.viewState.wikiPreviewTruncated = false; - this.viewState.wikiPreviewError = null; - } - private createGatewayState(snapshot = this.context.gateway.snapshot): DreamingState { return createDreamingState({ client: snapshot.client, @@ -515,8 +507,6 @@ class AgentMemoryPanel extends OpenClawLightDomElement { active: dreamingOn, selectedAgentId, shortTermCount: dreamingStatus?.shortTermCount ?? 0, - groundedSignalCount: dreamingStatus?.groundedSignalCount ?? 0, - totalSignalCount: dreamingStatus?.totalSignalCount ?? 0, promotedCount: dreamingStatus?.promotedToday ?? 0, phases: dreamingStatus?.phases ?? undefined, shortTermEntries: dreamingStatus?.shortTermEntries ?? [], @@ -524,7 +514,6 @@ class AgentMemoryPanel extends OpenClawLightDomElement { dreamingOf: null, nextCycle: resolveDreamingNextCycle(dreamingStatus), timezone: dreamingStatus?.timezone ?? null, - statusLoading: dreaming.dreamingStatusLoading, statusError: dreaming.dreamingStatusError, modeSaving: dreaming.dreamingModeSaving, dreamDiaryLoading: dreaming.dreamDiaryLoading, @@ -532,7 +521,6 @@ class AgentMemoryPanel extends OpenClawLightDomElement { dreamDiaryActionMessage: dreaming.dreamDiaryActionMessage, dreamDiaryActionArchivePath: dreaming.dreamDiaryActionArchivePath, dreamDiaryError: dreaming.dreamDiaryError, - dreamDiaryPath: dreaming.dreamDiaryPath, dreamDiaryContent: dreaming.dreamDiaryContent, memoryWikiEnabled: isPluginEnabledInConfigSnapshot( configState.configSnapshot, @@ -545,7 +533,6 @@ class AgentMemoryPanel extends OpenClawLightDomElement { wikiOverviewLoading: dreaming.wikiOverviewLoading, wikiOverviewError: dreaming.wikiOverviewError, wikiOverview: dreaming.wikiOverview, - onRefresh: () => void this.loadAll(true), onRefreshDiary: () => void this.runDreamingTask(loadDreamDiary), onRefreshImports: () => void this.refreshWikiData(loadWikiImportInsights), onRefreshWikiOverview: () => void this.refreshWikiData(loadWikiOverview), diff --git a/ui/src/pages/agents/memory/view.test.ts b/ui/src/pages/agents/memory/view.test.ts index d316f6cecef1..5c944ea6f81e 100644 --- a/ui/src/pages/agents/memory/view.test.ts +++ b/ui/src/pages/agents/memory/view.test.ts @@ -106,8 +106,6 @@ function buildProps(overrides?: Partial): DreamingProps { active: true, selectedAgentId: "main", shortTermCount: 47, - groundedSignalCount: 9, - totalSignalCount: 182, promotedCount: 12, phases: { light: { enabled: true, cron: "0 * * * *", nextRunAtMs: Date.parse("2026-04-05T11:30:00Z") }, @@ -150,7 +148,6 @@ function buildProps(overrides?: Partial): DreamingProps { dreamingOf: null, nextCycle: "4:00 AM", timezone: "America/Los_Angeles", - statusLoading: false, statusError: null, modeSaving: false, dreamDiaryLoading: false, @@ -158,7 +155,6 @@ function buildProps(overrides?: Partial): DreamingProps { dreamDiaryActionMessage: null, dreamDiaryActionArchivePath: null, dreamDiaryError: null, - dreamDiaryPath: "DREAMS.md", dreamDiaryContent: "# Dream Diary\n\n\n\n---\n\n*April 5, 2026, 3:00 AM*\n\nThe repository whispered of forgotten endpoints tonight.\n\n", memoryWikiEnabled: true, @@ -275,7 +271,6 @@ function buildProps(overrides?: Partial): DreamingProps { }, ], }, - onRefresh: () => {}, onRefreshDiary: () => {}, onRefreshImports: () => {}, onRefreshWikiOverview: () => {}, diff --git a/ui/src/pages/agents/memory/view.ts b/ui/src/pages/agents/memory/view.ts index 231bcec76d76..d29b2ba5bc2f 100644 --- a/ui/src/pages/agents/memory/view.ts +++ b/ui/src/pages/agents/memory/view.ts @@ -103,8 +103,6 @@ type DreamingProps = { active: boolean; selectedAgentId: string; shortTermCount: number; - groundedSignalCount: number; - totalSignalCount: number; promotedCount: number; phases?: { light: DreamingPhaseInfo; @@ -116,7 +114,6 @@ type DreamingProps = { dreamingOf: string | null; nextCycle: string | null; timezone: string | null; - statusLoading: boolean; statusError: string | null; modeSaving: boolean; dreamDiaryLoading: boolean; @@ -124,7 +121,6 @@ type DreamingProps = { dreamDiaryActionMessage: { kind: "success" | "error"; text: string } | null; dreamDiaryActionArchivePath: string | null; dreamDiaryError: string | null; - dreamDiaryPath: string | null; dreamDiaryContent: string | null; memoryWikiEnabled: boolean; wikiImportInsightsLoading: boolean; @@ -133,7 +129,6 @@ type DreamingProps = { wikiOverviewLoading: boolean; wikiOverviewError: string | null; wikiOverview: WikiOverview | null; - onRefresh: () => void; onRefreshDiary: () => void; onRefreshImports: () => void; onRefreshWikiOverview: () => void; @@ -465,19 +460,7 @@ function basename(value: string): string { } function formatKindLabel(kind: "entity" | "concept" | "source" | "synthesis" | "report"): string { - switch (kind) { - case "entity": - return t("dreaming.wiki.pageTypes.entity"); - case "concept": - return t("dreaming.wiki.pageTypes.concept"); - case "source": - return t("dreaming.wiki.pageTypes.source"); - case "synthesis": - return t("dreaming.wiki.pageTypes.synthesis"); - case "report": - return t("dreaming.wiki.pageTypes.report"); - } - return kind; + return t(`dreaming.wiki.pageTypes.${kind}`); } function formatPageCount(count: number): string { @@ -504,48 +487,20 @@ function formatContradictionCount(count: number): string { : t("dreaming.wiki.counts.contradictions", { count: String(count) }); } -function formatChatCount(count: number): string { - return t("dreaming.wiki.counts.chats", { count: String(count) }); -} - -function formatSignalCount(count: number): string { - return t("dreaming.wiki.counts.signals", { count: String(count) }); -} - -function formatMessageCount(count: number): string { - return t("dreaming.wiki.counts.messages", { count: String(count) }); -} - -const WIKI_OVERVIEW_PAGE_COUNT_ORDER: Array = [ - "source", - "synthesis", - "report", - "entity", - "concept", -]; - -function formatWikiOverviewPageCountLabel(kind: keyof WikiOverview["pageCounts"]): string { - switch (kind) { - case "source": - return t("dreaming.wiki.pageGroups.sources"); - case "synthesis": - return t("dreaming.wiki.pageGroups.syntheses"); - case "report": - return t("dreaming.wiki.pageGroups.reports"); - case "entity": - return t("dreaming.wiki.pageGroups.entities"); - case "concept": - return t("dreaming.wiki.pageGroups.concepts"); - } - return kind; -} +const WIKI_OVERVIEW_PAGE_GROUPS = [ + ["source", "sources"], + ["synthesis", "syntheses"], + ["report", "reports"], + ["entity", "entities"], + ["concept", "concepts"], +] as const; function formatWikiOverviewPageBreakdown(pageCounts: WikiOverview["pageCounts"]): string { - const parts = WIKI_OVERVIEW_PAGE_COUNT_ORDER.map((kind) => { + const parts = WIKI_OVERVIEW_PAGE_GROUPS.map(([kind, group]) => { const count = pageCounts[kind]; return count > 0 ? t("dreaming.wiki.pageGroupSummary", { - label: formatWikiOverviewPageCountLabel(kind), + label: t(`dreaming.wiki.pageGroups.${group}`), count: formatPageCount(count), }) : null; @@ -585,20 +540,11 @@ function formatImportBadge(item: { digestStatus: "available" | "withheld"; riskLevel: "low" | "medium" | "high" | "unknown"; }): string { - if (item.digestStatus === "withheld") { - return t("dreaming.wiki.risk.needsReview"); - } - switch (item.riskLevel) { - case "low": - return t("dreaming.wiki.risk.low"); - case "medium": - return t("dreaming.wiki.risk.medium"); - case "high": - return t("dreaming.wiki.risk.high"); - case "unknown": - return t("dreaming.wiki.risk.unknown"); - } - return t("dreaming.wiki.risk.unknown"); + return t( + item.digestStatus === "withheld" + ? "dreaming.wiki.risk.needsReview" + : `dreaming.wiki.risk.${item.riskLevel}`, + ); } function toggleExpandedCard(bucket: Set, key: string, onChange: () => void): void { @@ -651,7 +597,7 @@ async function openWikiPreview(lookup: string, props: DreamingProps): Promise${summary}
- - - - - + ${[ + { label: t("dreaming.scene.dedupeDiary"), onClick: props.onDedupeDreamDiary }, + { label: t("dreaming.scene.repairCache"), onClick: props.onRepairDreamingArtifacts }, + { + label: t( + props.dreamDiaryActionLoading + ? "dreaming.scene.working" + : "dreaming.scene.backfill", + ), + onClick: props.onBackfillDiary, + }, + { label: t("dreaming.scene.reset"), onClick: props.onResetDiary }, + { label: t("dreaming.scene.clearGrounded"), onClick: props.onResetGroundedShortTerm }, + ].map( + ({ label, onClick }) => html` + + `, + )}
${props.dreamDiaryActionMessage @@ -1023,264 +956,200 @@ function renderAdvancedSection(props: DreamingProps) { `; } -function renderDiaryImportsSection(props: DreamingProps) { +type ImportedInsightItem = WikiImportInsights["clusters"][number]["items"][number]; +type WikiPageItem = WikiOverview["clusters"][number]["items"][number]; +type WikiInsightCard = + | { kind: "import"; item: ImportedInsightItem } + | { kind: "wiki"; item: WikiPageItem }; + +function renderInsightList(labelKey: string, entries: string[]) { + return entries.length > 0 + ? html` +
+ ${t(labelKey)} + ${entries.map((entry) => html`

• ${entry}

`)} +
+ ` + : nothing; +} + +function renderInsightDetail(labelKey: string, value: string | undefined) { + return value + ? html` +

+ ${t(labelKey)} + ${value} +

+ ` + : nothing; +} + +function renderWikiInsightBody(card: WikiInsightCard, expanded: boolean) { + if (card.kind === "import") { + const item = card.item; + return html` +

${item.summary}

+ ${renderInsightList("dreaming.wiki.candidateSignals", item.candidateSignals)} + ${renderInsightList("dreaming.wiki.corrections", item.correctionSignals)} + ${expanded + ? html` +
+ ${t("dreaming.wiki.importDetails")} + ${renderInsightDetail("dreaming.wiki.startedWith", item.firstUserLine)} + ${renderInsightDetail( + "dreaming.wiki.endedOn", + item.lastUserLine !== item.firstUserLine ? item.lastUserLine : undefined, + )} + ${renderInsightDetail( + "dreaming.wiki.messages", + `${t("dreaming.wiki.counts.userMessages", { + count: String(item.userMessageCount), + })} · ${t("dreaming.wiki.counts.assistantMessages", { + count: String(item.assistantMessageCount), + })}`, + )} + ${renderInsightDetail("dreaming.wiki.riskReasons", item.riskReasons.join(", "))} + ${renderInsightDetail("dreaming.wiki.labels", item.labels.join(", "))} +
+ ` + : nothing} + ${item.preferenceSignals.length > 0 + ? html` +
+ ${item.preferenceSignals.map( + (signal) => html`${signal}`, + )} +
+ ` + : nothing} + `; + } + + const item = card.item; + return html` + ${item.snippet ? html`

${item.snippet}

` : nothing} + ${renderInsightList("dreaming.wiki.claims", item.claims)} + ${renderInsightList("dreaming.wiki.openQuestions", item.questions)} + ${renderInsightList("dreaming.wiki.contradictions", item.contradictions)} + ${expanded + ? html` +
+ ${t("dreaming.wiki.pageDetails")} + ${renderInsightDetail("dreaming.wiki.wikiPage", item.pagePath)} + ${renderInsightDetail("dreaming.wiki.id", item.id)} +
+ ` + : nothing} + `; +} + +function renderWikiInsightCard(props: DreamingProps, card: WikiInsightCard) { const state = props.viewState; - const importInsights = props.wikiImportInsights; - const clusters = importInsights?.clusters ?? []; - - if (props.wikiImportInsightsLoading && clusters.length === 0) { - return html` -
-
${t("dreaming.wiki.loadingInsights")}
-
- `; - } - - if (clusters.length === 0) { - return html` -
-
${t("dreaming.wiki.noInsights")}
-
${t("dreaming.wiki.noInsightsHint")}
-
- `; - } - - const clusterIndex = Math.max(0, Math.min(state.diaryPage, clusters.length - 1)); - const cluster = expectDefined(clusters[clusterIndex], "selected imported insight cluster"); - const clusterMeta = [ - formatChatCount(cluster.itemCount), - ...(cluster.highRiskCount > 0 - ? [ - t("dreaming.wiki.counts.sensitive", { - count: String(cluster.highRiskCount), - }), - ] - : []), - ...(cluster.preferenceSignalCount > 0 - ? [formatSignalCount(cluster.preferenceSignalCount)] - : []), - ]; - const importSummary = [ - t("dreaming.wiki.importedClusterSummary", { - label: cluster.label.toLowerCase(), - }), - ...(cluster.withheldCount > 0 - ? [ - cluster.withheldCount === 1 - ? t("dreaming.wiki.withheldDigestOne", { - count: String(cluster.withheldCount), - }) - : t("dreaming.wiki.withheldDigests", { - count: String(cluster.withheldCount), - }), - ] - : []), - ]; + const item = card.item; + const expandedCards = + card.kind === "import" ? state.expandedInsightCards : state.expandedWikiCards; + const expanded = expandedCards.has(item.pagePath); + const badgeClass = card.kind === "import" ? card.item.riskLevel : "wiki"; + const badgeLabel = + card.kind === "import" ? formatImportBadge(card.item) : formatKindLabel(card.item.kind); + const metadata = + card.kind === "import" + ? card.item.activeBranchMessages > 0 + ? ` · ${t("dreaming.wiki.counts.messages", { + count: String(card.item.activeBranchMessages), + })}` + : "" + : ` · ${item.pagePath}`; return html` -
- ${clusters.map( - (entry, index) => html` - - `, - )} -
- -
-
-
${cluster.label} · ${clusterMeta.join(" · ")}
-
-

${importSummary.join(" ")}

+
{ + if (card.kind === "wiki" && card.item.kind === "report") { + void openWikiPreview(item.pagePath, props); + return; + } + toggleExpandedCard(expandedCards, item.pagePath, props.onViewStateChange); + }} + > +
+
${item.title}
+ + ${badgeLabel} +
-
- ${cluster.items.map((item) => { - const expanded = state.expandedInsightCards.has(item.pagePath); - return html` -
- toggleExpandedCard( - state.expandedInsightCards, - item.pagePath, - props.onViewStateChange, - )} - > -
-
${item.title}
- - ${formatImportBadge(item)} - -
-
- ${item.updatedAt ? formatCompactDateTime(item.updatedAt) : basename(item.pagePath)} - ${item.activeBranchMessages > 0 - ? ` · ${formatMessageCount(item.activeBranchMessages)}` - : ""} -
-

${item.summary}

- ${item.candidateSignals.length > 0 - ? html` -
- ${t("dreaming.wiki.candidateSignals")} - ${item.candidateSignals.map( - (signal) => html`

• ${signal}

`, - )} -
- ` - : nothing} - ${item.correctionSignals.length > 0 - ? html` -
- ${t("dreaming.wiki.corrections")} - ${item.correctionSignals.map( - (signal) => html`

• ${signal}

`, - )} -
- ` - : nothing} - ${expanded - ? html` -
- ${t("dreaming.wiki.importDetails")} - ${item.firstUserLine - ? html` -

- ${t("dreaming.wiki.startedWith")} - ${item.firstUserLine} -

- ` - : nothing} - ${item.lastUserLine && item.lastUserLine !== item.firstUserLine - ? html` -

- ${t("dreaming.wiki.endedOn")} - ${item.lastUserLine} -

- ` - : nothing} -

- ${t("dreaming.wiki.messages")} - ${t("dreaming.wiki.counts.userMessages", { - count: String(item.userMessageCount), - })} - · - ${t("dreaming.wiki.counts.assistantMessages", { - count: String(item.assistantMessageCount), - })} -

- ${item.riskReasons.length > 0 - ? html` -

- ${t("dreaming.wiki.riskReasons")} - ${item.riskReasons.join(", ")} -

- ` - : nothing} - ${item.labels.length > 0 - ? html` -

- ${t("dreaming.wiki.labels")} - ${item.labels.join(", ")} -

- ` - : nothing} -
- ` - : nothing} - ${item.preferenceSignals.length > 0 - ? html` -
- ${item.preferenceSignals.map( - (signal) => - html`${signal}`, - )} -
- ` - : nothing} -
- - -
-
- `; - })} +
+ ${item.updatedAt + ? formatCompactDateTime(item.updatedAt) + : basename(item.pagePath)}${metadata} +
+ ${renderWikiInsightBody(card, expanded)} +
+ +
`; } -function renderWikiOverviewSection(props: DreamingProps) { - const state = props.viewState; - const overview = props.wikiOverview; - const clusters = overview?.clusters ?? []; - - if (props.wikiOverviewLoading && clusters.length === 0) { - return html` -
-
${t("dreaming.wiki.loadingWiki")}
-
- `; - } - +function renderWikiClusterSection< + Cluster extends { key: string; label: string; items: { pagePath: string }[] }, +>( + props: DreamingProps, + params: { + kind: "imports" | "wiki"; + clusters: Cluster[]; + loading: boolean; + loadingKey: string; + emptyKey: string; + emptyHintKey: string; + date: (cluster: Cluster) => string; + prose: (cluster: Cluster) => ReturnType; + renderItem: (item: Cluster["items"][number]) => ReturnType; + }, +) { + const { clusters } = params; if (clusters.length === 0) { return html`
-
${t("dreaming.wiki.emptyWiki")}
-
${t("dreaming.wiki.emptyWikiHint")}
+
+ ${t(params.loading ? params.loadingKey : params.emptyKey)} +
+ ${params.loading + ? nothing + : html`
${t(params.emptyHintKey)}
`}
`; } + const state = props.viewState; const clusterIndex = Math.max(0, Math.min(state.diaryPage, clusters.length - 1)); - const cluster = expectDefined(clusters[clusterIndex], "selected memory overview cluster"); - const totalPages = overview?.totalPages ?? overview?.totalItems ?? 0; - const totalClaims = overview?.totalClaims ?? 0; - const totalQuestions = overview?.totalQuestions ?? 0; - const totalContradictions = overview?.totalContradictions ?? 0; - const pageBreakdown = overview - ? formatWikiOverviewPageBreakdown(overview.pageCounts) - : t("dreaming.wiki.noPagesYet"); - const clusterSummary = formatWikiOverviewClusterSummary(cluster); - const vaultMeta = [ - formatPageCount(totalPages), - ...(totalClaims > 0 ? [formatClaimRowCount(totalClaims)] : []), - ...(totalQuestions > 0 ? [formatOpenQuestionCount(totalQuestions)] : []), - ...(totalContradictions > 0 ? [formatContradictionCount(totalContradictions)] : []), - ]; - + const cluster = expectDefined( + clusters[clusterIndex], + params.kind === "imports" + ? "selected imported insight cluster" + : "selected memory overview cluster", + ); return html`
${clusters.map( @@ -1299,132 +1168,100 @@ function renderWikiOverviewSection(props: DreamingProps) { `, )}
- -
+
-
${t("dreaming.wiki.vault")} · ${vaultMeta.join(" · ")}
-
-

- ${t("dreaming.wiki.fullVaultBreakdown", { breakdown: pageBreakdown })} -

-

- ${t("dreaming.wiki.selectedSection", { summary: clusterSummary })} - ${cluster.updatedAt - ? ` ${t("dreaming.wiki.latestUpdate", { - date: formatCompactDateTime(cluster.updatedAt), - })}` - : ""} -

-
-
- ${cluster.items.map((item) => { - const expanded = state.expandedWikiCards.has(item.pagePath); - return html` -
{ - if (item.kind === "report") { - void openWikiPreview(item.pagePath, props); - return; - } - toggleExpandedCard(state.expandedWikiCards, item.pagePath, props.onViewStateChange); - }} - > -
-
${item.title}
- - ${formatKindLabel(item.kind)} - -
-
- ${item.updatedAt ? formatCompactDateTime(item.updatedAt) : basename(item.pagePath)} - · ${item.pagePath} -
- ${item.snippet - ? html`

${item.snippet}

` - : nothing} - ${item.claims.length > 0 - ? html` -
- ${t("dreaming.wiki.claims")} - ${item.claims.map( - (claim) => html`

• ${claim}

`, - )} -
- ` - : nothing} - ${item.questions.length > 0 - ? html` -
- ${t("dreaming.wiki.openQuestions")} - ${item.questions.map( - (question) => html`

• ${question}

`, - )} -
- ` - : nothing} - ${item.contradictions.length > 0 - ? html` -
- ${t("dreaming.wiki.contradictions")} - ${item.contradictions.map( - (entry) => html`

• ${entry}

`, - )} -
- ` - : nothing} - ${expanded - ? html` -
- ${t("dreaming.wiki.pageDetails")} -

- ${t("dreaming.wiki.wikiPage")} - ${item.pagePath} -

- ${item.id - ? html` -

- ${t("dreaming.wiki.id")} - ${item.id} -

- ` - : nothing} -
- ` - : nothing} -
- - -
-
- `; - })} -
+
${params.date(cluster)}
+
${params.prose(cluster)}
+
${cluster.items.map(params.renderItem)}
`; } +function renderDiaryImportsSection(props: DreamingProps) { + return renderWikiClusterSection(props, { + kind: "imports", + clusters: props.wikiImportInsights?.clusters ?? [], + loading: props.wikiImportInsightsLoading, + loadingKey: "dreaming.wiki.loadingInsights", + emptyKey: "dreaming.wiki.noInsights", + emptyHintKey: "dreaming.wiki.noInsightsHint", + date: (cluster) => { + const metadata = [ + t("dreaming.wiki.counts.chats", { count: String(cluster.itemCount) }), + ...(cluster.highRiskCount > 0 + ? [t("dreaming.wiki.counts.sensitive", { count: String(cluster.highRiskCount) })] + : []), + ...(cluster.preferenceSignalCount > 0 + ? [t("dreaming.wiki.counts.signals", { count: String(cluster.preferenceSignalCount) })] + : []), + ]; + return `${cluster.label} · ${metadata.join(" · ")}`; + }, + prose: (cluster) => { + const summary = [ + t("dreaming.wiki.importedClusterSummary", { label: cluster.label.toLowerCase() }), + ...(cluster.withheldCount > 0 + ? [ + t( + cluster.withheldCount === 1 + ? "dreaming.wiki.withheldDigestOne" + : "dreaming.wiki.withheldDigests", + { count: String(cluster.withheldCount) }, + ), + ] + : []), + ]; + return html`

${summary.join(" ")}

`; + }, + renderItem: (item) => renderWikiInsightCard(props, { kind: "import", item }), + }); +} + +function renderWikiOverviewSection(props: DreamingProps) { + const overview = props.wikiOverview; + return renderWikiClusterSection(props, { + kind: "wiki", + clusters: overview?.clusters ?? [], + loading: props.wikiOverviewLoading, + loadingKey: "dreaming.wiki.loadingWiki", + emptyKey: "dreaming.wiki.emptyWiki", + emptyHintKey: "dreaming.wiki.emptyWikiHint", + date: () => { + const metadata = [ + formatPageCount(overview?.totalPages ?? 0), + ...((overview?.totalClaims ?? 0) > 0 ? [formatClaimRowCount(overview!.totalClaims)] : []), + ...((overview?.totalQuestions ?? 0) > 0 + ? [formatOpenQuestionCount(overview!.totalQuestions)] + : []), + ...((overview?.totalContradictions ?? 0) > 0 + ? [formatContradictionCount(overview!.totalContradictions)] + : []), + ]; + return `${t("dreaming.wiki.vault")} · ${metadata.join(" · ")}`; + }, + prose: (cluster) => html` +

+ ${t("dreaming.wiki.fullVaultBreakdown", { + breakdown: overview + ? formatWikiOverviewPageBreakdown(overview.pageCounts) + : t("dreaming.wiki.noPagesYet"), + })} +

+

+ ${t("dreaming.wiki.selectedSection", { + summary: formatWikiOverviewClusterSummary(cluster), + })} + ${cluster.updatedAt + ? ` ${t("dreaming.wiki.latestUpdate", { + date: formatCompactDateTime(cluster.updatedAt), + })}` + : ""} +

+ `, + renderItem: (item) => renderWikiInsightCard(props, { kind: "wiki", item }), + }); +} + function renderDreamDiaryEntries(props: DreamingProps) { const state = props.viewState; if (typeof props.dreamDiaryContent !== "string") { diff --git a/ui/src/pages/channels/view.detail.ts b/ui/src/pages/channels/view.detail.ts index 5108524cf93e..84d565f87d69 100644 --- a/ui/src/pages/channels/view.detail.ts +++ b/ui/src/pages/channels/view.detail.ts @@ -1,32 +1,172 @@ // Channel detail overlay: full status + advanced schema config form for one // channel, reusing the per-channel settings-language renderers. +import { asNullableRecord, readStringField } from "@openclaw/normalization-core/record-coerce"; import { html, nothing, type TemplateResult } from "lit"; -import type { ChannelAccountSnapshot, NostrProfile } from "../../api/types.ts"; +import type { NostrProfile } from "../../api/types.ts"; import { renderSettingsSection } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; import "../../components/modal-dialog.ts"; +import { resolveChannelAccounts } from "../../lib/channels/index.ts"; +import { formatRelativeTimestamp } from "../../lib/format.ts"; import { channelDocsUrl, renderChannelArt } from "./hub-meta.ts"; import { renderChannelConfigSection } from "./view.config.ts"; -import { renderDiscordCard } from "./view.discord.ts"; -import { renderGoogleChatCard } from "./view.googlechat.ts"; -import { renderIMessageCard } from "./view.imessage.ts"; import { renderNostrCard } from "./view.nostr.ts"; import { renderChannelPairingDetail } from "./view.pairing.ts"; import { boolStatusKind, formatNullableBoolean, renderChannelAccountRow, + renderChannelActionRow, renderChannelErrorRow, renderChannelFacts, + renderChannelProbeRow, resolveChannelAccountCount, resolveChannelDisplayState, } from "./view.shared.ts"; -import { renderSignalCard } from "./view.signal.ts"; -import { renderSlackCard } from "./view.slack.ts"; -import { renderTelegramCard } from "./view.telegram.ts"; import type { ChannelKey, ChannelsChannelData, ChannelsProps } from "./view.types.ts"; import { renderWhatsAppCard } from "./view.whatsapp.ts"; +const STANDARD_CHANNEL_LOCALE_KEYS = { + discord: "discord", + googlechat: "googleChat", + imessage: "imessage", + signal: "signal", + slack: "slack", + telegram: "telegram", +} as const; + +type StandardChannelKey = keyof typeof STANDARD_CHANNEL_LOCALE_KEYS; + +function isStandardChannel(key: ChannelKey): key is StandardChannelKey { + return Object.hasOwn(STANDARD_CHANNEL_LOCALE_KEYS, key); +} + +function renderChannelStatusBody( + key: ChannelKey, + props: ChannelsProps, + data: ChannelsChannelData, + accountCount: number | undefined, +) { + const standardKey = isStandardChannel(key) ? key : null; + const localeKey = standardKey ? STANDARD_CHANNEL_LOCALE_KEYS[standardKey] : null; + const status = standardKey ? data[standardKey] : undefined; + const displayState = resolveChannelDisplayState(key, props); + const configured = displayState.configured; + const accounts = resolveChannelAccounts(data.channelAccounts, key); + const showAccounts = + standardKey === "telegram" ? accounts.length > 1 : !standardKey && accounts.length > 0; + const extraRows = + standardKey === "googlechat" + ? [ + { + label: t("common.credential"), + value: data.googlechat?.credentialSource ?? t("common.na"), + }, + { + label: t("common.audience"), + value: data.googlechat?.audienceType + ? `${data.googlechat.audienceType}${data.googlechat.audience ? ` · ${data.googlechat.audience}` : ""}` + : t("common.na"), + }, + ] + : standardKey === "signal" + ? [{ label: t("common.baseUrl"), value: data.signal?.baseUrl ?? t("common.na") }] + : standardKey === "telegram" + ? [{ label: t("common.mode"), value: data.telegram?.mode ?? t("common.na") }] + : []; + const statusRows = [ + { + label: t("common.configured"), + value: formatNullableBoolean(configured), + kind: boolStatusKind(configured), + }, + { + label: t("common.running"), + value: !standardKey + ? formatNullableBoolean(displayState.running) + : standardKey === "googlechat" && !status + ? t("common.na") + : formatNullableBoolean(status?.running ?? false), + kind: boolStatusKind(standardKey ? status?.running : displayState.running), + }, + ...(standardKey + ? [ + ...extraRows, + ...(["lastStartAt", "lastProbeAt"] as const).map((field) => ({ + label: t(field === "lastStartAt" ? "common.lastStart" : "common.lastProbe"), + value: status?.[field] ? formatRelativeTimestamp(status[field]) : t("common.na"), + })), + ] + : [ + { + label: t("common.connected"), + value: formatNullableBoolean(displayState.connected), + kind: boolStatusKind(displayState.connected), + }, + ]), + ]; + const lastError = readStringField( + asNullableRecord(standardKey ? status : displayState.status), + "lastError", + ); + + return renderSettingsSection( + { + title: localeKey + ? t(`channels.${localeKey}.title`) + : (readStringField(props.snapshot?.channelLabels, key) ?? key), + description: localeKey ? t(`channels.${localeKey}.subtitle`) : t("channels.generic.subtitle"), + ...(accountCount !== undefined ? { count: accountCount } : {}), + }, + html` + ${showAccounts + ? accounts.map((account) => { + const username = + standardKey === "telegram" + ? readStringField( + asNullableRecord(asNullableRecord(account.probe)?.bot), + "username", + ) + : undefined; + return renderChannelAccountRow({ + title: username ? `@${username}` : account.name || account.accountId, + accountId: account.accountId, + ...(standardKey === "telegram" + ? { + facts: [ + `${t("common.configured")}: ${account.configured ? t("common.yes") : t("common.no")}`, + ], + } + : {}), + status: { + kind: boolStatusKind( + standardKey === "telegram" + ? account.running + : (account.running ?? account.configured), + ), + label: account.running + ? t("common.running") + : !standardKey && account.configured + ? t("common.configured") + : t("common.no"), + }, + lastInboundAt: account.lastInboundAt, + lastError: account.lastError, + }); + }) + : renderChannelFacts(statusRows)} + ${lastError ? renderChannelErrorRow(lastError) : nothing} + ${standardKey && status?.probe ? renderChannelProbeRow(status.probe) : nothing} + ${renderChannelConfigSection({ channelId: key, props })} + ${standardKey + ? renderChannelActionRow(html``) + : nothing} + `, + ); +} + function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: ChannelsChannelData) { const accountCount = resolveChannelAccountCount(key, data.channelAccounts); switch (key) { @@ -36,45 +176,8 @@ function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: Channels whatsapp: data.whatsapp, accountCount, }); - case "telegram": - return renderTelegramCard({ - props, - telegram: data.telegram, - telegramAccounts: data.channelAccounts?.telegram ?? [], - accountCount, - }); - case "discord": - return renderDiscordCard({ - props, - discord: data.discord, - accountCount, - }); - case "googlechat": - return renderGoogleChatCard({ - props, - googleChat: data.googlechat, - accountCount, - }); - case "slack": - return renderSlackCard({ - props, - slack: data.slack, - accountCount, - }); - case "signal": - return renderSignalCard({ - props, - signal: data.signal, - accountCount, - }); - case "imessage": - return renderIMessageCard({ - props, - imessage: data.imessage, - accountCount, - }); case "nostr": { - const nostrAccounts = data.channelAccounts?.nostr ?? []; + const nostrAccounts = resolveChannelAccounts(data.channelAccounts, "nostr"); const primaryAccount = nostrAccounts[0]; const accountId = primaryAccount?.accountId ?? "default"; const profile = @@ -101,69 +204,10 @@ function renderChannelBody(key: ChannelKey, props: ChannelsProps, data: Channels }); } default: - return renderGenericChannelBody(key, props, data.channelAccounts ?? {}); + return renderChannelStatusBody(key, props, data, accountCount); } } -function renderGenericChannelBody( - key: ChannelKey, - props: ChannelsProps, - channelAccounts: Record, -) { - const label = props.snapshot?.channelLabels?.[key] ?? key; - const displayState = resolveChannelDisplayState(key, props); - const lastError = - typeof displayState.status?.lastError === "string" ? displayState.status.lastError : undefined; - const accounts = channelAccounts[key] ?? []; - const accountCount = resolveChannelAccountCount(key, channelAccounts); - - return renderSettingsSection( - { - title: label, - description: t("channels.generic.subtitle"), - ...(accountCount !== undefined ? { count: accountCount } : {}), - }, - html` - ${accounts.length > 0 - ? accounts.map((account) => - renderChannelAccountRow({ - title: account.name || account.accountId, - accountId: account.accountId, - status: { - kind: boolStatusKind(account.running ?? account.configured), - label: account.running - ? t("common.running") - : account.configured - ? t("common.configured") - : t("common.no"), - }, - lastInboundAt: account.lastInboundAt, - lastError: account.lastError, - }), - ) - : renderChannelFacts([ - { - label: t("common.configured"), - value: formatNullableBoolean(displayState.configured), - kind: boolStatusKind(displayState.configured), - }, - { - label: t("common.running"), - value: formatNullableBoolean(displayState.running), - kind: boolStatusKind(displayState.running), - }, - { - label: t("common.connected"), - value: formatNullableBoolean(displayState.connected), - kind: boolStatusKind(displayState.connected), - }, - ])} - ${lastError ? renderChannelErrorRow(lastError) : nothing} - ${renderChannelConfigSection({ channelId: key, props })} - `, - ); -} - export function renderChannelDetail(params: { channelId: string; label: string; diff --git a/ui/src/pages/channels/view.discord.ts b/ui/src/pages/channels/view.discord.ts deleted file mode 100644 index 78d6047bd467..000000000000 --- a/ui/src/pages/channels/view.discord.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Channels page renders Discord status. -import { html, nothing } from "lit"; -import type { DiscordStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderDiscordCard(params: { - props: ChannelsProps; - discord?: DiscordStatus | null; - accountCount?: number; -}) { - const { props, discord, accountCount } = params; - const configured = resolveChannelConfigured("discord", props); - - return renderSingleAccountChannelCard({ - title: t("channels.discord.title"), - subtitle: t("channels.discord.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: discord?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(discord?.running), - }, - { - label: t("common.lastStart"), - value: discord?.lastStartAt ? formatRelativeTimestamp(discord.lastStartAt) : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: discord?.lastProbeAt ? formatRelativeTimestamp(discord.lastProbeAt) : t("common.na"), - }, - ], - lastError: discord?.lastError, - secondaryCallout: discord?.probe ? renderChannelProbeRow(discord.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "discord", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.googlechat.ts b/ui/src/pages/channels/view.googlechat.ts deleted file mode 100644 index 2f06c0fadb8d..000000000000 --- a/ui/src/pages/channels/view.googlechat.ts +++ /dev/null @@ -1,70 +0,0 @@ -// Channels page renders Google Chat status. -import { html, nothing } from "lit"; -import type { GoogleChatStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderGoogleChatCard(params: { - props: ChannelsProps; - googleChat?: GoogleChatStatus | null; - accountCount?: number; -}) { - const { props, googleChat, accountCount } = params; - const configured = resolveChannelConfigured("googlechat", props); - - return renderSingleAccountChannelCard({ - title: t("channels.googleChat.title"), - subtitle: t("channels.googleChat.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: googleChat - ? googleChat.running - ? t("common.yes") - : t("common.no") - : t("common.na"), - kind: boolStatusKind(googleChat?.running), - }, - { label: t("common.credential"), value: googleChat?.credentialSource ?? t("common.na") }, - { - label: t("common.audience"), - value: googleChat?.audienceType - ? `${googleChat.audienceType}${googleChat.audience ? ` · ${googleChat.audience}` : ""}` - : t("common.na"), - }, - { - label: t("common.lastStart"), - value: googleChat?.lastStartAt - ? formatRelativeTimestamp(googleChat.lastStartAt) - : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: googleChat?.lastProbeAt - ? formatRelativeTimestamp(googleChat.lastProbeAt) - : t("common.na"), - }, - ], - lastError: googleChat?.lastError, - secondaryCallout: googleChat?.probe ? renderChannelProbeRow(googleChat.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "googlechat", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.imessage.ts b/ui/src/pages/channels/view.imessage.ts deleted file mode 100644 index ad4e05169054..000000000000 --- a/ui/src/pages/channels/view.imessage.ts +++ /dev/null @@ -1,59 +0,0 @@ -// Channels page renders iMessage status. -import { html, nothing } from "lit"; -import type { IMessageStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderIMessageCard(params: { - props: ChannelsProps; - imessage?: IMessageStatus | null; - accountCount?: number; -}) { - const { props, imessage, accountCount } = params; - const configured = resolveChannelConfigured("imessage", props); - - return renderSingleAccountChannelCard({ - title: t("channels.imessage.title"), - subtitle: t("channels.imessage.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: imessage?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(imessage?.running), - }, - { - label: t("common.lastStart"), - value: imessage?.lastStartAt - ? formatRelativeTimestamp(imessage.lastStartAt) - : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: imessage?.lastProbeAt - ? formatRelativeTimestamp(imessage.lastProbeAt) - : t("common.na"), - }, - ], - lastError: imessage?.lastError, - secondaryCallout: imessage?.probe ? renderChannelProbeRow(imessage.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "imessage", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.shared.ts b/ui/src/pages/channels/view.shared.ts index 54c3342980a8..40fc8bfd8a41 100644 --- a/ui/src/pages/channels/view.shared.ts +++ b/ui/src/pages/channels/view.shared.ts @@ -1,9 +1,10 @@ // Channels page shared view helpers. +import { asNullableRecord } from "@openclaw/normalization-core/record-coerce"; import { html, nothing } from "lit"; import type { ChannelAccountSnapshot } from "../../api/types.ts"; import { renderSettingsSection, renderSettingsStatus } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; -import { channelSnapshotEntryIsActive } from "../../lib/channels/index.ts"; +import { channelSnapshotEntryIsActive, resolveChannelAccounts } from "../../lib/channels/index.ts"; import { formatRelativeTimestamp } from "../../lib/format.ts"; import type { ChannelKey, ChannelsProps } from "./view.types.ts"; @@ -28,16 +29,20 @@ function resolveChannelStatus( key: ChannelKey, props: ChannelsProps, ): Record | undefined { - const channels = props.snapshot?.channels as Record | null; - return channels?.[key] as Record | undefined; + const channels = props.snapshot?.channels; + return channels && Object.hasOwn(channels, key) + ? (asNullableRecord(channels[key]) ?? undefined) + : undefined; } function resolveDefaultChannelAccount( key: ChannelKey, props: ChannelsProps, ): ChannelAccountSnapshot | null { - const accounts = props.snapshot?.channelAccounts?.[key] ?? []; - const defaultAccountId = props.snapshot?.channelDefaultAccountId?.[key]; + const accounts = resolveChannelAccounts(props.snapshot?.channelAccounts, key); + const defaultAccountIds = props.snapshot?.channelDefaultAccountId; + const defaultAccountId = + defaultAccountIds && Object.hasOwn(defaultAccountIds, key) ? defaultAccountIds[key] : undefined; return ( (defaultAccountId ? accounts.find((account) => account.accountId === defaultAccountId) @@ -219,18 +224,11 @@ export function renderSingleAccountChannelCard(params: { ); } -function getChannelAccountCount( - key: ChannelKey, - channelAccounts?: Record | null, -): number { - return channelAccounts?.[key]?.length ?? 0; -} - /** Multi-account channels surface the account count next to the heading. */ export function resolveChannelAccountCount( key: ChannelKey, channelAccounts?: Record | null, ): number | undefined { - const count = getChannelAccountCount(key, channelAccounts); + const count = resolveChannelAccounts(channelAccounts, key).length; return count >= 2 ? count : undefined; } diff --git a/ui/src/pages/channels/view.signal.ts b/ui/src/pages/channels/view.signal.ts deleted file mode 100644 index e94b32de2b5f..000000000000 --- a/ui/src/pages/channels/view.signal.ts +++ /dev/null @@ -1,56 +0,0 @@ -// Channels page renders Signal status. -import { html, nothing } from "lit"; -import type { SignalStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderSignalCard(params: { - props: ChannelsProps; - signal?: SignalStatus | null; - accountCount?: number; -}) { - const { props, signal, accountCount } = params; - const configured = resolveChannelConfigured("signal", props); - - return renderSingleAccountChannelCard({ - title: t("channels.signal.title"), - subtitle: t("channels.signal.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: signal?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(signal?.running), - }, - { label: t("common.baseUrl"), value: signal?.baseUrl ?? t("common.na") }, - { - label: t("common.lastStart"), - value: signal?.lastStartAt ? formatRelativeTimestamp(signal.lastStartAt) : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: signal?.lastProbeAt ? formatRelativeTimestamp(signal.lastProbeAt) : t("common.na"), - }, - ], - lastError: signal?.lastError, - secondaryCallout: signal?.probe ? renderChannelProbeRow(signal.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "signal", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.slack.ts b/ui/src/pages/channels/view.slack.ts deleted file mode 100644 index 4c9457af36ac..000000000000 --- a/ui/src/pages/channels/view.slack.ts +++ /dev/null @@ -1,55 +0,0 @@ -// Channels page renders Slack status. -import { html, nothing } from "lit"; -import type { SlackStatus } from "../../api/types.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderSlackCard(params: { - props: ChannelsProps; - slack?: SlackStatus | null; - accountCount?: number; -}) { - const { props, slack, accountCount } = params; - const configured = resolveChannelConfigured("slack", props); - - return renderSingleAccountChannelCard({ - title: t("channels.slack.title"), - subtitle: t("channels.slack.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: slack?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(slack?.running), - }, - { - label: t("common.lastStart"), - value: slack?.lastStartAt ? formatRelativeTimestamp(slack.lastStartAt) : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: slack?.lastProbeAt ? formatRelativeTimestamp(slack.lastProbeAt) : t("common.na"), - }, - ], - lastError: slack?.lastError, - secondaryCallout: slack?.probe ? renderChannelProbeRow(slack.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "slack", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.telegram.ts b/ui/src/pages/channels/view.telegram.ts deleted file mode 100644 index 2a32597e39c3..000000000000 --- a/ui/src/pages/channels/view.telegram.ts +++ /dev/null @@ -1,106 +0,0 @@ -// Channels page renders Telegram status. -import { html, nothing } from "lit"; -import type { ChannelAccountSnapshot, TelegramStatus } from "../../api/types.ts"; -import { renderSettingsSection } from "../../components/settings-ui.ts"; -import { t } from "../../i18n/index.ts"; -import { formatRelativeTimestamp } from "../../lib/format.ts"; -import { renderChannelConfigSection } from "./view.config.ts"; -import { - boolStatusKind, - formatNullableBoolean, - renderChannelAccountRow, - renderChannelActionRow, - renderChannelErrorRow, - renderChannelProbeRow, - renderSingleAccountChannelCard, - resolveChannelConfigured, -} from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; - -export function renderTelegramCard(params: { - props: ChannelsProps; - telegram?: TelegramStatus; - telegramAccounts: ChannelAccountSnapshot[]; - accountCount?: number; -}) { - const { props, telegram, telegramAccounts, accountCount } = params; - const hasMultipleAccounts = telegramAccounts.length > 1; - const configured = resolveChannelConfigured("telegram", props); - - const renderAccountRow = (account: ChannelAccountSnapshot) => { - const probe = account.probe as { bot?: { username?: string } } | undefined; - const botUsername = probe?.bot?.username; - const label = account.name || account.accountId; - return renderChannelAccountRow({ - title: botUsername ? `@${botUsername}` : label, - accountId: account.accountId, - facts: [ - `${t("common.configured")}: ${account.configured ? t("common.yes") : t("common.no")}`, - ], - status: { - kind: boolStatusKind(account.running), - label: account.running ? t("common.running") : t("common.no"), - }, - lastInboundAt: account.lastInboundAt, - lastError: account.lastError, - }); - }; - - if (hasMultipleAccounts) { - return renderSettingsSection( - { - title: t("channels.telegram.title"), - description: t("channels.telegram.subtitle"), - ...(accountCount !== undefined ? { count: accountCount } : {}), - }, - html` - ${telegramAccounts.map((account) => renderAccountRow(account))} - ${telegram?.lastError ? renderChannelErrorRow(telegram.lastError) : nothing} - ${telegram?.probe ? renderChannelProbeRow(telegram.probe) : nothing} - ${renderChannelConfigSection({ channelId: "telegram", props })} - ${renderChannelActionRow( - html``, - )} - `, - ); - } - - return renderSingleAccountChannelCard({ - title: t("channels.telegram.title"), - subtitle: t("channels.telegram.subtitle"), - accountCount, - statusRows: [ - { - label: t("common.configured"), - value: formatNullableBoolean(configured), - kind: boolStatusKind(configured), - }, - { - label: t("common.running"), - value: telegram?.running ? t("common.yes") : t("common.no"), - kind: boolStatusKind(telegram?.running), - }, - { label: t("common.mode"), value: telegram?.mode ?? t("common.na") }, - { - label: t("common.lastStart"), - value: telegram?.lastStartAt - ? formatRelativeTimestamp(telegram.lastStartAt) - : t("common.na"), - }, - { - label: t("common.lastProbe"), - value: telegram?.lastProbeAt - ? formatRelativeTimestamp(telegram.lastProbeAt) - : t("common.na"), - }, - ], - lastError: telegram?.lastError, - secondaryCallout: telegram?.probe ? renderChannelProbeRow(telegram.probe) : nothing, - configSection: renderChannelConfigSection({ channelId: "telegram", props }), - footer: html``, - }); -} diff --git a/ui/src/pages/channels/view.test.ts b/ui/src/pages/channels/view.test.ts index 6c3386f7878c..5285dfea8a94 100644 --- a/ui/src/pages/channels/view.test.ts +++ b/ui/src/pages/channels/view.test.ts @@ -8,7 +8,8 @@ import { resolveChannelConfigured, resolveChannelDisplayState, } from "./view.shared.ts"; -import type { ChannelsProps } from "./view.types.ts"; +import { renderChannels } from "./view.ts"; +import type { ChannelsChannelData, ChannelsProps } from "./view.types.ts"; import { renderWhatsAppCard } from "./view.whatsapp.ts"; function createProps(snapshot: ChannelsProps["snapshot"]): ChannelsProps { @@ -122,6 +123,40 @@ function renderWhatsAppButtons(params: { }; } +function renderChannelDetailFixture( + channelId: string, + data: ChannelsChannelData, + options: { label?: string; onRefresh?: ChannelsProps["onRefresh"] } = {}, +) { + const status = Object.entries(data).find(([key]) => key === channelId)?.[1] ?? {}; + const channelAccounts = data.channelAccounts ?? {}; + const accounts = Object.hasOwn(channelAccounts, channelId) ? channelAccounts[channelId] : []; + const props = createProps({ + ts: Date.now(), + channelOrder: [channelId], + channelLabels: { [channelId]: options.label ?? channelId }, + channels: { [channelId]: status }, + channelAccounts, + channelDefaultAccountId: accounts?.length ? { [channelId]: accounts[0]!.accountId } : {}, + }); + if (options.onRefresh) { + props.onRefresh = options.onRefresh; + } + const container = document.createElement("div"); + render( + renderChannelDetail({ + channelId, + label: options.label ?? channelId, + props, + data: { ...data, channelAccounts }, + onClose: () => {}, + onSetup: () => {}, + }), + container, + ); + return container; +} + // Mirrors the tiers the gateway materializes on every channel schema path. const CHANNEL_TIER_SCHEMA = { type: "object", @@ -257,6 +292,103 @@ describe("channel detail", () => { expect(docs?.href).toBe("https://docs.openclaw.ai/channels/telegram"); expect(docs?.textContent?.trim()).toBe("Docs"); }); + + it.each([ + ["discord", "Discord", []], + ["slack", "Slack", []], + ["signal", "Signal", [["Base URL", "https://signal.example"]]], + ["imessage", "iMessage", []], + [ + "googlechat", + "Google Chat", + [ + ["Credential", "service-account"], + ["Audience", "url · https://chat.example"], + ], + ], + ["telegram", "Telegram", [["Mode", "polling"]]], + ] satisfies Array<[string, string, Array<[string, string]>]>)( + "preserves localized status facts and probe actions for %s", + (channelId, title, extraFacts) => { + const onRefresh = vi.fn(); + const status = { + configured: true, + running: true, + baseUrl: "https://signal.example", + credentialSource: "service-account", + audienceType: "url", + audience: "https://chat.example", + mode: "polling", + }; + const data: ChannelsChannelData = { channelAccounts: {}, [channelId]: status }; + const container = renderChannelDetailFixture(channelId, data, { onRefresh }); + const facts = Array.from(container.querySelectorAll("dt"), (node) => [ + node.textContent?.trim(), + node.nextElementSibling?.textContent?.trim(), + ]); + + expect(container.querySelector(".settings-section__heading")?.textContent?.trim()).toBe( + title, + ); + expect(facts).toEqual([ + ["Configured", "Yes"], + ["Running", "Yes"], + ...extraFacts, + ["Last start", "n/a"], + ["Last probe", "n/a"], + ]); + container.querySelector(".settings-row--actions button")!.click(); + expect(onRefresh).toHaveBeenCalledWith(true); + }, + ); + + it("keeps missing Google Chat status unknown while other known channels are stopped", () => { + const google = renderChannelDetailFixture("googlechat", { googlechat: null }); + const discord = renderChannelDetailFixture("discord", { discord: null }); + const fact = (container: HTMLElement, label: string) => + Array.from(container.querySelectorAll("dt")) + .find((node) => node.textContent?.trim() === label) + ?.nextElementSibling?.textContent?.trim(); + + expect(fact(google, "Running")).toBe("n/a"); + expect(fact(discord, "Running")).toBe("No"); + }); + + it.each(["guildchat", "constructor", "__proto__"])( + "opens accountless plugin %s from its actual hub row without inherited account values", + (channelId) => { + for (const configured of [false, true]) { + const props = createProps({ + ts: Date.now(), + channelOrder: [channelId], + channelLabels: { [channelId]: "Custom channel" }, + channels: { [channelId]: { configured, running: configured } }, + channelAccounts: {}, + channelDefaultAccountId: {}, + }); + const container = document.createElement("div"); + props.onShowDetail = (selected) => { + props.selectedChannel = selected; + render(renderChannels(props), container); + }; + render(renderChannels(props), container); + const trigger = container.querySelector( + configured ? "button.channels-item" : ".channels-item__detail", + ); + + expect(trigger).toBeInstanceOf(HTMLButtonElement); + trigger!.click(); + const detail = container.querySelector(".channels-detail"); + expect(detail?.querySelector(".settings-section__heading")?.textContent?.trim()).toBe( + "Custom channel", + ); + expect(detail?.textContent).toContain("Channel status and configuration."); + expect( + Array.from(detail!.querySelectorAll("dt"), (node) => node.textContent?.trim()), + ).toEqual(["Configured", "Running", "Connected"]); + } + }, + ); }); describe("channel display selectors", () => { diff --git a/ui/src/pages/channels/view.ts b/ui/src/pages/channels/view.ts index 2276f10e0842..0e9caaf5c992 100644 --- a/ui/src/pages/channels/view.ts +++ b/ui/src/pages/channels/view.ts @@ -3,9 +3,7 @@ import { html, nothing } from "lit"; import "../../styles/channels.css"; import type { - ChannelAccountSnapshot, ChannelsStatusSnapshot, - ChannelUiMetaEntry, DiscordStatus, GoogleChatStatus, IMessageStatus, @@ -24,6 +22,7 @@ import { renderSettingsStatus, } from "../../components/settings-ui.ts"; import { t } from "../../i18n/index.ts"; +import { resolveChannelAccounts } from "../../lib/channels/index.ts"; import { formatRelativeTimestamp } from "../../lib/format.ts"; import { renderChannelArt } from "./hub-meta.ts"; import { renderChannelDetail } from "./view.detail.ts"; @@ -156,26 +155,23 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe return ["whatsapp", "telegram", "discord", "googlechat", "slack", "signal", "imessage", "nostr"]; } -function resolveChannelMetaMap( - snapshot: ChannelsStatusSnapshot | null, -): Record { - if (!snapshot?.channelMeta?.length) { - return {}; - } - return Object.fromEntries(snapshot.channelMeta.map((entry) => [entry.id, entry])); -} - function resolveChannelLabel(snapshot: ChannelsStatusSnapshot | null, key: string): string { - const meta = resolveChannelMetaMap(snapshot)[key]; - return meta?.label ?? snapshot?.channelLabels?.[key] ?? key; + const labels = snapshot?.channelLabels; + return ( + snapshot?.channelMeta?.find((entry) => entry.id === key)?.label ?? + (labels && Object.hasOwn(labels, key) ? labels[key] : undefined) ?? + key + ); } function resolveChannelDetailLabel( snapshot: ChannelsStatusSnapshot | null, key: string, ): string | null { - const meta = resolveChannelMetaMap(snapshot)[key]; - const detail = meta?.detailLabel ?? snapshot?.channelDetailLabels?.[key] ?? null; + const labels = snapshot?.channelDetailLabels; + const detail = + snapshot?.channelMeta?.find((entry) => entry.id === key)?.detailLabel ?? + (labels && Object.hasOwn(labels, key) ? labels[key] : null); return detail && detail !== resolveChannelLabel(snapshot, key) ? detail : null; } @@ -184,8 +180,9 @@ function resolveRowState(key: ChannelKey, props: ChannelsProps): ChannelCardStat const lastError = typeof displayState.status?.lastError === "string" && displayState.status.lastError.trim() ? displayState.status.lastError - : (props.snapshot?.channelAccounts?.[key] ?? []).find((account) => account.lastError) - ?.lastError; + : resolveChannelAccounts(props.snapshot?.channelAccounts, key).find( + (account) => account.lastError, + )?.lastError; if (lastError) { return "attention"; } @@ -209,10 +206,10 @@ function rowStatus(state: ChannelCardState) { } function lastActivityLine(key: ChannelKey, props: ChannelsProps): string | null { - const accounts: ChannelAccountSnapshot[] = props.snapshot?.channelAccounts?.[key] ?? []; - const lastInbound = accounts - .map((account) => account.lastInboundAt ?? 0) - .reduce((a, b) => Math.max(a, b), 0); + const lastInbound = resolveChannelAccounts(props.snapshot?.channelAccounts, key).reduce( + (latest, account) => Math.max(latest, account.lastInboundAt ?? 0), + 0, + ); if (!lastInbound) { return null; } diff --git a/ui/src/pages/channels/wizard-controller.ts b/ui/src/pages/channels/wizard-controller.ts index a43146318f55..474febd85610 100644 --- a/ui/src/pages/channels/wizard-controller.ts +++ b/ui/src/pages/channels/wizard-controller.ts @@ -1,5 +1,6 @@ // Drives a gateway channel-setup wizard session (wizard.start flow "channels") // as a step/answer state machine for the Control UI wizard modal. +import type { WizardStep } from "../../api/types.ts"; import { isWizardNotFoundError } from "../../lib/gateway-errors.ts"; type WizardGatewayClient = { @@ -37,26 +38,7 @@ async function requestWithTimeout( } } -export type ChannelWizardStepOption = { - value: unknown; - label: string; - hint?: string; -}; - -export type ChannelWizardStep = { - id: string; - type: "note" | "select" | "text" | "confirm" | "multiselect" | "progress" | "action"; - title?: string; - message?: string; - format?: "plain"; - options?: ChannelWizardStepOption[]; - initialValue?: unknown; - placeholder?: string; - sensitive?: boolean; - executor?: "gateway" | "client"; - externalUrl?: string; - deviceCode?: { code: string; expiresInMinutes?: number; message?: string }; -}; +export type ChannelWizardStep = WizardStep; type WizardNextResult = { sessionId?: string; diff --git a/ui/src/pages/channels/wizard-view.ts b/ui/src/pages/channels/wizard-view.ts index 2feabe472b07..04e1eca53f88 100644 --- a/ui/src/pages/channels/wizard-view.ts +++ b/ui/src/pages/channels/wizard-view.ts @@ -3,15 +3,12 @@ import "@awesome.me/webawesome/dist/components/radio/radio.js"; import "@awesome.me/webawesome/dist/components/radio-group/radio-group.js"; import { html, nothing, type TemplateResult } from "lit"; +import { renderWizardStepControls } from "../../components/wizard-step-controls.ts"; import { t } from "../../i18n/index.ts"; import "../../components/modal-dialog.ts"; import { copyToClipboard } from "../../lib/clipboard.ts"; import { channelDocsUrl, channelHubMeta, renderChannelArt } from "./hub-meta.ts"; -import type { - ChannelWizardState, - ChannelWizardStep, - ChannelWizardStepOption, -} from "./wizard-controller.ts"; +import type { ChannelWizardState, ChannelWizardStep } from "./wizard-controller.ts"; type ChannelWizardViewProps = { wizard: ChannelWizardState; @@ -30,10 +27,6 @@ type ChannelWizardViewProps = { onWhatsAppWait: () => void; }; -function stepKeyboardValue(step: ChannelWizardStep): string { - return typeof step.initialValue === "string" ? step.initialValue : ""; -} - function stepIsBusy(props: ChannelWizardViewProps): boolean { return props.wizard.phase === "step" && props.wizard.busy; } @@ -72,151 +65,20 @@ function renderNoteStep(step: ChannelWizardStep, props: ChannelWizardViewProps) `; } -function renderSelectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - const options = step.options ?? []; - const selectedIndex = options.findIndex((option) => option.value === step.initialValue); - return html` - = 0 ? String(selectedIndex) : null} - ?disabled=${stepIsBusy(props)} - @change=${(event: Event) => { - const rawIndex = (event.currentTarget as HTMLElement & { value?: string | number | null }) - .value; - const option = options[Number(rawIndex)]; - if (option) { - props.onAnswer(option.value); - } - }} - > - ${options.map( - (option: ChannelWizardStepOption, index) => html` - - ${option.label} - ${option.hint - ? html`${option.hint}` - : nothing} - - `, - )} - - `; -} - -function renderMultiselectStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - const options = step.options ?? []; - const selected = new Set(props.multiselectValues); - return html` -
${step.message ?? ""}
-
- ${options.map( - (option: ChannelWizardStepOption) => html` - - `, - )} -
- - `; -} - -function renderTextStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - const submit = (event: Event) => { - event.preventDefault(); - const form = event.currentTarget as HTMLFormElement; - const input = form.elements.namedItem("wizard-text") as HTMLInputElement | null; - props.onAnswer(input?.value ?? ""); - }; - return html` -
-
- -
- - -
- `; -} - -function renderConfirmStep(step: ChannelWizardStep, props: ChannelWizardViewProps) { - return html` -
${step.message ?? ""}
- - `; -} - function renderStepBody(step: ChannelWizardStep, props: ChannelWizardViewProps) { - switch (step.type) { - case "select": - return renderSelectStep(step, props); - case "multiselect": - return renderMultiselectStep(step, props); - case "text": - return renderTextStep(step, props); - case "confirm": - return renderConfirmStep(step, props); - default: - return renderNoteStep(step, props); + if (step.type === "note" || step.type === "progress" || step.type === "action") { + return renderNoteStep(step, props); } + return renderWizardStepControls({ + step, + value: step.type === "multiselect" ? props.multiselectValues : step.initialValue, + busy: stepIsBusy(props), + inputId: "channel-wizard-text-input", + presentation: "channels", + answerLabel: t("channels.setup.continue"), + onValueChange: props.onToggleMultiselect, + onAnswer: props.onAnswer, + }); } function renderWhatsAppLinking(props: ChannelWizardViewProps) { @@ -285,23 +147,17 @@ function renderDoneBody(channels: readonly string[], props: ChannelWizardViewPro if (channels.includes("whatsapp")) { return renderWhatsAppLinking(props); } - if (channels.length === 0) { - return html` -
${t("channels.setup.doneNoChangesTitle")}
-
${t("channels.setup.doneNoChangesBody")}
- - `; - } + const changed = channels.length > 0; return html` -
${t("channels.setup.doneTitle")}
-
${t("channels.setup.doneBody")}
+
+ ${t(changed ? "channels.setup.doneTitle" : "channels.setup.doneNoChangesTitle")} +
+
+ ${t(changed ? "channels.setup.doneBody" : "channels.setup.doneNoChangesBody")} +
`; diff --git a/ui/src/pages/chat/realtime-talk-audio.test.ts b/ui/src/pages/chat/realtime-talk-audio.test.ts index b0db4684e9b6..bb771a9e6263 100644 --- a/ui/src/pages/chat/realtime-talk-audio.test.ts +++ b/ui/src/pages/chat/realtime-talk-audio.test.ts @@ -95,6 +95,44 @@ describe("RealtimeTalkMediaStreamMeter", () => { expect(close).toHaveBeenCalledOnce(); }); + it("reclaims its interval when the initial level callback stops it", () => { + vi.useFakeTimers(); + const close = vi.fn(async () => undefined); + const disconnectSource = vi.fn(); + const disconnectAnalyser = vi.fn(); + class MockAudioContext { + readonly close = close; + createMediaStreamSource() { + return { connect: vi.fn(), disconnect: disconnectSource }; + } + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect: disconnectAnalyser, + getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25), + }; + } + } + vi.stubGlobal("AudioContext", MockAudioContext); + const onLevel = vi.fn((level: number) => { + if (level > 0) { + meter.stop(); + } + }); + const meter = new RealtimeTalkMediaStreamMeter(onLevel); + + meter.start({} as MediaStream); + meter.stop(); + meter.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(disconnectSource).toHaveBeenCalledOnce(); + expect(disconnectAnalyser).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + it("closes an owned AudioContext when analyser setup fails", () => { const close = vi.fn(async () => undefined); class MockAudioContext { diff --git a/ui/src/pages/chat/realtime-talk-audio.ts b/ui/src/pages/chat/realtime-talk-audio.ts index 8b4511d69ecd..e4a264ee1f41 100644 --- a/ui/src/pages/chat/realtime-talk-audio.ts +++ b/ui/src/pages/chat/realtime-talk-audio.ts @@ -160,8 +160,10 @@ export class RealtimeTalkMediaStreamMeter { analyser.fftSize = this.samples.length; analyser.smoothingTimeConstant = 0; source.connect(analyser); - this.publishCurrentLevel(); this.timer = globalThis.setInterval(() => this.publishCurrentLevel(), 100); + // The initial level callback can synchronously stop its owning transport. + // Own the interval first so that reentrant cleanup cannot leave it behind. + this.publishCurrentLevel(); } catch { // Metering is feedback only; capture must still work if Web Audio analysis // is unavailable in an otherwise functional WebRTC browser. diff --git a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts index aac2ba302117..a729faa6a7e8 100644 --- a/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts +++ b/ui/src/pages/chat/realtime-talk-gateway-relay.test.ts @@ -614,6 +614,25 @@ describe("GatewayRelayRealtimeTalkTransport", () => { expect(onInputLevel).toHaveBeenLastCalledWith(0); }); + it("reclaims the input meter when its first level update stops the transport", async () => { + vi.useFakeTimers(); + const client = createClient(); + const onInputLevel = vi.fn((level: number) => { + if (level > 0) { + transport.stop(); + } + }); + const transport = createTransport({ client, callbacks: { onInputLevel } }); + + await expect(transport.start()).resolves.toBe("ready"); + transport.stop(); + transport.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(requestCallsFor(client, "talk.session.close")).toHaveLength(1); + }); + it("bounds stalled microphone appends and aborts every owner on stop", async () => { const onStatus = vi.fn(); const client = createClient(); diff --git a/ui/src/pages/chat/realtime-talk-google-live.ts b/ui/src/pages/chat/realtime-talk-google-live.ts index d507c45ec251..2dbc4f909723 100644 --- a/ui/src/pages/chat/realtime-talk-google-live.ts +++ b/ui/src/pages/chat/realtime-talk-google-live.ts @@ -216,11 +216,6 @@ export class GoogleLiveRealtimeTalkTransport implements RealtimeTalkTransport { const inputMeter = new RealtimeTalkMediaStreamMeter(this.ctx.callbacks.onInputLevel); this.inputMeter = inputMeter; inputMeter.start(this.media, this.inputContext); - if (this.closed || !this.lifecycle.isActive || this.inputMeter !== inputMeter) { - // start() publishes synchronously before installing its interval. A - // reentrant stop must reclaim the interval that start() installs next. - inputMeter.stop(false); - } this.assertActivationCurrent(); } this.startMicrophonePump(); diff --git a/ui/src/pages/chat/realtime-talk-webrtc.test.ts b/ui/src/pages/chat/realtime-talk-webrtc.test.ts index 6bef1ac0e370..8a662e211f2d 100644 --- a/ui/src/pages/chat/realtime-talk-webrtc.test.ts +++ b/ui/src/pages/chat/realtime-talk-webrtc.test.ts @@ -223,6 +223,42 @@ describe("WebRtcSdpRealtimeTalkTransport", () => { expect(close).toHaveBeenCalledOnce(); }); + it("reclaims the input meter when its first level update stops the transport", async () => { + vi.useFakeTimers(); + stubAnswerSdpFetch(); + const close = vi.fn(async () => undefined); + class MockAudioContext { + readonly close = close; + createMediaStreamSource() { + return { connect: vi.fn(), disconnect: vi.fn() }; + } + createAnalyser() { + return { + fftSize: 0, + smoothingTimeConstant: 0, + disconnect: vi.fn(), + getFloatTimeDomainData: (samples: Float32Array) => samples.fill(0.25), + }; + } + } + vi.stubGlobal("AudioContext", MockAudioContext); + const onInputLevel = vi.fn((level: number) => { + if (level > 0) { + transport.stop(); + } + }); + const transport = createOpenAiTransport({}, { onInputLevel }); + + await expect(transport.start()).resolves.toBe("cancelled"); + transport.stop(); + transport.stop(); + vi.advanceTimersByTime(1_000); + + expect(vi.getTimerCount()).toBe(0); + expect(stopInputTrack).toHaveBeenCalledOnce(); + expect(close).toHaveBeenCalledOnce(); + }); + it("does not continue WebRTC setup when stopped while microphone access is pending", async () => { const fetchMock = vi.fn(async () => new Response("answer-sdp")); vi.stubGlobal("fetch", fetchMock as unknown as typeof fetch); diff --git a/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts b/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts index f537cfa092bc..12c9e29d307b 100644 --- a/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts +++ b/ui/src/test-helpers/app-sidebar-cases/session-ownership.ts @@ -130,6 +130,38 @@ describe("AppSidebar session ownership", () => { expect(carolChip?.textContent?.trim()).toBe("C"); }); + it("keeps emoji display-name initials as whole grapheme clusters", async () => { + for (const { label, expected } of [ + { label: "🦞小明", expected: "🦞" }, + { label: "👨‍👩‍👧‍👦Family", expected: "👨‍👩‍👧‍👦" }, + ]) { + const gateway = createGateway({} as GatewayBrowserClient); + const harness = createSessionsHarness("main", ["agent:main:main", "agent:main:lobster"]); + const result = harness.sessions.state.result; + if (!result) { + throw new Error("expected session list"); + } + const lobster = result.sessions.find((row) => row.key.endsWith(":lobster")); + if (!lobster) { + throw new Error("expected creator row"); + } + lobster.createdActor = { type: "human", id: "profile-lobster", label }; + result.creators = [ + { id: "profile-lobster", label }, + { id: "profile-ada", label: "Ada" }, + ]; + + const { sidebar } = await mountSidebar(gateway, harness.sessions); + harness.publishList({ result, agentId: "main" }); + await sidebar.updateComplete; + + const chip = sidebar.querySelector( + '[data-session-key="agent:main:lobster"] .session-owner-chip', + ); + expect(chip?.textContent?.trim()).toBe(expected); + } + }); + it("uses the complete facet and requests unloaded creators from the Gateway", async () => { const gateway = createGateway({} as GatewayBrowserClient); const harness = createSessionsHarness("main", ["agent:main:main", "agent:main:ada"]);