mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 02:45:38 -06:00
Merge remote-tracking branch 'origin/main' into meow/workboard-modal-drawer-a11y
# Conflicts: # ui/src/ui/views/workboard.ts
This commit is contained in:
@@ -0,0 +1,156 @@
|
||||
name: Blacksmith ARM Testbox
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testbox_id:
|
||||
type: string
|
||||
description: "Testbox session ID"
|
||||
required: true
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
|
||||
jobs:
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
@@ -139,139 +139,3 @@ jobs:
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
@@ -120,6 +120,21 @@ jobs:
|
||||
append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir
|
||||
append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency
|
||||
append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir
|
||||
reset_crabbox_pnpm_path() {
|
||||
local path="$1"
|
||||
if [ -z "$path" ]; then
|
||||
return
|
||||
fi
|
||||
case "$path" in
|
||||
/var/tmp/openclaw-pnpm-*) rm -rf "$path" ;;
|
||||
esac
|
||||
}
|
||||
reset_crabbox_pnpm_path "${PNPM_CONFIG_MODULES_DIR:-}"
|
||||
reset_crabbox_pnpm_path "${PNPM_CONFIG_STORE_DIR:-}"
|
||||
reset_crabbox_pnpm_path "${PNPM_CONFIG_VIRTUAL_STORE_DIR:-}"
|
||||
if [ -L node_modules ] && [ "$(readlink node_modules)" = "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
rm -f node_modules
|
||||
fi
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
|
||||
@@ -47,6 +47,29 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
- Agents/providers: avoid loading owner plugin runtimes for explicitly configured custom provider models during OpenAI-compatible transport setup.
|
||||
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
|
||||
- Release/CI/E2E: reset shared Crabbox pnpm hydrate state before installs so stale `/var/tmp` stores cannot leave `pnpm install` spinning after completion.
|
||||
- Release/CI/E2E: print heartbeat progress during centralized Docker builds while keeping successful build logs quiet.
|
||||
- Release/CI/E2E: avoid heartbeat-tail delays in Docker E2E log wrappers while reporting captured log bytes during long runs.
|
||||
- Release/CI/E2E: keep release user-journey logs and temporary plugin fixtures under per-run scratch roots so parallel runs cannot collide or leak artifacts.
|
||||
- Release/CI/E2E: bound release candidate GitHub API calls so stalled network requests cannot wedge workflow and artifact polling.
|
||||
- Release/CI/E2E: bound Discord smoke API calls in cross-OS release checks so host-side round trips cannot hang on stalled fetches.
|
||||
- Release/CI/E2E: bound RPC RTT gateway readiness probes so a half-open local HTTP response cannot stall cleanup past the readiness deadline.
|
||||
- Scripts/UI: stop descendant processes from wrapped non-interactive commands when `run-with-env` receives shutdown signals.
|
||||
- Release/CI/E2E: write multi-node update Docker artifacts to unique per-run directories by default so parallel runs cannot overwrite evidence.
|
||||
- Release/CI/E2E: write package Telegram Docker artifacts to unique per-run directories by default so parallel live/RTT runs cannot overwrite evidence.
|
||||
- Release/CI/E2E: keep plugin lifecycle matrix resource artifacts under a unique per-run scratch root so parallel runs cannot overwrite tarballs or inspect output.
|
||||
- Release/CI/E2E: bound mock OpenAI readiness probes in web-search and Telegram RTT Docker smokes so stalled HTTP accepts cannot hang cleanup or fall through.
|
||||
- Tooling: cancel oversized pnpm audit advisory responses before failing so registry error paths do not leave response bodies open.
|
||||
- Release/CI/E2E: stop tracked gateway and mock service process groups so descendant helpers do not survive E2E cleanup.
|
||||
- Release/CI/E2E: fail secret-provider proof runs when temporary state cleanup still fails after retries instead of hiding the cleanup error.
|
||||
- Release/CI/E2E: fail package-candidate ref proofs when temporary source worktree cleanup fails instead of leaving stale worktrees behind.
|
||||
- Release/CI/E2E: remove package tarball extract directories when tar extraction fails before validation can continue.
|
||||
- Release/CI/E2E: retry generated temp-state cleanup after removal failures and route plugin lifecycle measurement edits to their owner tests.
|
||||
- Release/CI/E2E: close parent gateway log handles after spawning RPC RTT probes so repeated measurements do not leak file descriptors.
|
||||
- Release/CI/E2E: fail RPC RTT probes when temporary state cleanup fails instead of hiding leftover scratch directories.
|
||||
- Release/CI/E2E: fail Kitchen Sink RPC walks when temporary state cleanup still fails after retries instead of silently preserving scratch roots.
|
||||
- Control UI: lazy-load the usage view so the initial app bundle stays below the chunk warning threshold.
|
||||
- Build: keep Baileys optional image backends external so source builds do not warn about missing `jimp` or `sharp`.
|
||||
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
|
||||
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
|
||||
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
|
||||
|
||||
@@ -6896,6 +6896,20 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMetadataParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMessageGetParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f3e0379cbe0e584a8c9658253d4a808356fe80fb5ec775bbee9e968e8d815380 plugin-sdk-api-baseline.json
|
||||
601b55acafbd1e00b850c9b0c15d587029050906960071d448d37538b223e226 plugin-sdk-api-baseline.jsonl
|
||||
a9501e226bb26befb02072cf5e60c3dc124cbd5dc0b16eb281789d0843f72f71 plugin-sdk-api-baseline.json
|
||||
b106090dc12bf7e46beac4ed160f0cff0ef8039291f24172b693e8d8b752d571 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -319,6 +319,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
|
||||
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
|
||||
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
|
||||
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
|
||||
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
|
||||
|
||||
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
|
||||
|
||||
@@ -205,6 +205,8 @@ Each policy field below is optional. A check runs only when the matching rule is
|
||||
present in `policy.jsonc`. The observed state is existing OpenClaw config or
|
||||
workspace metadata; policy reports drift but does not rewrite runtime behavior
|
||||
unless a repair path is explicitly available and enabled.
|
||||
Policy files are strict: unsupported sections or rule keys are reported as
|
||||
`policy/policy-jsonc-invalid` instead of being ignored.
|
||||
|
||||
Policy overlays keep broad top-level rules global, then let named scope blocks
|
||||
add stricter normal policy sections for explicit selectors. A scope name is a
|
||||
|
||||
@@ -194,10 +194,12 @@ OpenClaw resolves that behavior by conversation type:
|
||||
`message(action=send)`.
|
||||
- Internal orchestration allows silence by default.
|
||||
|
||||
OpenClaw also uses silent replies for internal runner failures that happen
|
||||
before any assistant reply in non-direct chats, so groups/channels do not see
|
||||
gateway error boilerplate. Direct chats show compact failure copy by default;
|
||||
raw runner details are shown only when `/verbose full` is enabled.
|
||||
OpenClaw also uses silent replies for generic internal runner failures in
|
||||
non-direct chats, so groups/channels do not see gateway error boilerplate.
|
||||
Classified failures with user-facing recovery copy, such as missing auth,
|
||||
rate-limit, or overload notices, can still be delivered. Direct chats show
|
||||
compact failure copy by default; raw runner details are shown only when
|
||||
`/verbose full` is enabled.
|
||||
|
||||
Defaults live under `agents.defaults.silentReply`; `surfaces.<id>.silentReply`
|
||||
can override group/internal policy per surface.
|
||||
|
||||
@@ -292,7 +292,8 @@ Workboard stops auto-moving that card until you move it back to `todo` or
|
||||
2. Create a card with a title, notes, priority, labels, optional agent, and
|
||||
optional linked session.
|
||||
3. Or open Sessions and choose Add to Workboard for an existing session.
|
||||
4. Drag the card between columns or use the column controls.
|
||||
4. Drag the card between columns or focus the compact status control on the card
|
||||
and use its menu or ArrowLeft/ArrowRight.
|
||||
5. Start work from the card to create or reuse a dashboard session.
|
||||
6. Open the linked session from the card while the agent works.
|
||||
7. Let lifecycle sync move running work into review or blocked, then manually
|
||||
|
||||
@@ -7,7 +7,11 @@ import { startCodexAttemptThread } from "./attempt-startup.js";
|
||||
import { defaultLeasedCodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { CodexAppServerClient } from "./client.js";
|
||||
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { clearSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
import { createClientHarness, createCodexTestModel } from "./test-support.js";
|
||||
|
||||
type ClientHarness = ReturnType<typeof createClientHarness>;
|
||||
@@ -51,14 +55,24 @@ function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: st
|
||||
function startThreadWithHarness(
|
||||
startupTimeoutMs: number,
|
||||
signal = new AbortController().signal,
|
||||
overrides?: { pluginConfig?: CodexPluginConfig },
|
||||
overrides?: {
|
||||
pluginConfig?: CodexPluginConfig;
|
||||
attemptClientFactory?: (
|
||||
harness: ClientHarness,
|
||||
) => Parameters<typeof startCodexAttemptThread>[0]["attemptClientFactory"];
|
||||
harness?: ClientHarness;
|
||||
skipStartSpy?: boolean;
|
||||
},
|
||||
) {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const harness = overrides?.harness ?? createClientHarness();
|
||||
if (!overrides?.skipStartSpy) {
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
}
|
||||
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
|
||||
|
||||
const run = startCodexAttemptThread({
|
||||
attemptClientFactory: defaultLeasedCodexAppServerClientFactory,
|
||||
attemptClientFactory:
|
||||
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
|
||||
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
|
||||
pluginConfig: effectivePluginConfig,
|
||||
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
|
||||
@@ -147,8 +161,50 @@ describe("startCodexAttemptThread", () => {
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("retires a failed startup client after another active lease releases", async () => {
|
||||
const retained = createClientHarness();
|
||||
const replacement = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(retained.client)
|
||||
.mockReturnValueOnce(replacement.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
|
||||
harness: retained,
|
||||
skipStartSpy: true,
|
||||
});
|
||||
const threadStart = await waitForThreadStart(retained);
|
||||
retained.send({
|
||||
id: threadStart.id,
|
||||
error: { code: -32000, message: "401 authentication_error: Invalid bearer token" },
|
||||
});
|
||||
|
||||
await expect(run).rejects.toThrow("Invalid bearer token");
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
|
||||
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
|
||||
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
|
||||
|
||||
const replacementLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
await answerInitialize(replacement);
|
||||
await expect(replacementLease).resolves.toBe(replacement.client);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
|
||||
const { harness, run } = startThreadWithHarness(200);
|
||||
const { harness, run } = startThreadWithHarness(2_000);
|
||||
const runError = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
@@ -166,6 +222,78 @@ describe("startCodexAttemptThread", () => {
|
||||
expect(harness.stdinDestroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
|
||||
const retained = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
harness: retained,
|
||||
skipStartSpy: true,
|
||||
});
|
||||
const threadStart = await waitForThreadStart(retained);
|
||||
|
||||
await expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
|
||||
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
|
||||
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
|
||||
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
|
||||
});
|
||||
|
||||
it("closes the shared app-server when startup times out during initialize", async () => {
|
||||
const { harness, run } = startThreadWithHarness(100);
|
||||
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
await expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
|
||||
interval: 1,
|
||||
timeout: 2_000,
|
||||
});
|
||||
expect(
|
||||
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("closes a startup client that arrives after startup timeout", async () => {
|
||||
let observedFactoryOptions:
|
||||
| {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
}
|
||||
| undefined;
|
||||
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
attemptClientFactory:
|
||||
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
|
||||
observedFactoryOptions = options;
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
options?.onStartedClient?.(factoryHarness.client);
|
||||
return factoryHarness.client;
|
||||
},
|
||||
});
|
||||
|
||||
await expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
|
||||
interval: 1,
|
||||
timeout: 2_000,
|
||||
});
|
||||
expect(
|
||||
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
|
||||
).toBe(false);
|
||||
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
|
||||
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {
|
||||
const abortController = new AbortController();
|
||||
const { harness, run } = startThreadWithHarness(5_000, abortController.signal);
|
||||
|
||||
@@ -44,8 +44,10 @@ import {
|
||||
type CodexSandboxExecEnvironment,
|
||||
} from "./sandbox-exec-server.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
retireSharedCodexAppServerClientIfCurrent,
|
||||
} from "./shared-client.js";
|
||||
import {
|
||||
startOrResumeThread,
|
||||
@@ -102,13 +104,23 @@ export async function startCodexAttemptThread(params: {
|
||||
let releaseSharedClientLease: (() => void) | undefined;
|
||||
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined;
|
||||
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
|
||||
let startupAbandoned = false;
|
||||
const startupAbandonController = new AbortController();
|
||||
const abandonStartupAcquire = () => startupAbandonController.abort();
|
||||
params.signal.addEventListener("abort", abandonStartupAcquire, { once: true });
|
||||
try {
|
||||
const startupResult = await withCodexStartupTimeout({
|
||||
timeoutMs: params.startupTimeoutMs,
|
||||
signal: params.signal,
|
||||
onTimeout: async () => {
|
||||
startupAbandoned = true;
|
||||
startupAbandonController.abort();
|
||||
await params.onStartupTimeout();
|
||||
await releaseStartupResourcesOnTimeout?.();
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
},
|
||||
operation: async () => {
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
@@ -172,25 +184,48 @@ export async function startCodexAttemptThread(params: {
|
||||
let attemptedClient: CodexAppServerClient | undefined;
|
||||
const startupAttempt = async () => {
|
||||
let startupClientLease: (() => void) | undefined;
|
||||
let startupClient: CodexAppServerClient | undefined;
|
||||
let startupAttemptError: unknown;
|
||||
let startupAttemptSucceeded = false;
|
||||
try {
|
||||
const startupClient = await params.attemptClientFactory(
|
||||
startupClient = await params.attemptClientFactory(
|
||||
params.appServer.start,
|
||||
params.startupAuthProfileId,
|
||||
params.agentDir,
|
||||
params.config,
|
||||
{
|
||||
onStartedClient: (client) => {
|
||||
startupClientForAbandonedRequestCleanup = client;
|
||||
if (startupAbandoned || startupAbandonController.signal.aborted) {
|
||||
void closeAbandonedStartupClient(client);
|
||||
}
|
||||
},
|
||||
abandonSignal: startupAbandonController.signal,
|
||||
},
|
||||
);
|
||||
const activeStartupClient = startupClient;
|
||||
let startupClientLeaseReleased = false;
|
||||
startupClientLease = () => {
|
||||
releaseLeasedSharedCodexAppServerClient(startupClient);
|
||||
if (startupClientLeaseReleased) {
|
||||
return;
|
||||
}
|
||||
startupClientLeaseReleased = true;
|
||||
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
|
||||
};
|
||||
releaseSharedClientLease = startupClientLease;
|
||||
attemptedClient = startupClient;
|
||||
startupClientForAbandonedRequestCleanup = startupClient;
|
||||
attemptedClient = activeStartupClient;
|
||||
startupClientForAbandonedRequestCleanup = activeStartupClient;
|
||||
if (startupAbandoned) {
|
||||
throw new Error("codex app-server startup timed out");
|
||||
}
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
await ensureCodexComputerUse({
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
pluginConfig: params.pluginConfig,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal: params.signal,
|
||||
signal: startupAbandonController.signal,
|
||||
});
|
||||
let startupSandboxEnvironment: CodexSandboxExecEnvironment | undefined;
|
||||
let startupSandboxEnvironmentAcquired = false;
|
||||
@@ -208,15 +243,15 @@ export async function startCodexAttemptThread(params: {
|
||||
sandboxExecServerEnabled: params.sandboxExecServerEnabled,
|
||||
})
|
||||
? await ensureCodexSandboxExecServerEnvironment({
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
sandbox: params.sandbox ?? null,
|
||||
appServerStartOptions: params.appServer.start,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal: params.signal,
|
||||
signal: startupAbandonController.signal,
|
||||
})
|
||||
: undefined;
|
||||
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
|
||||
if (params.signal.aborted) {
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
@@ -246,9 +281,9 @@ export async function startCodexAttemptThread(params: {
|
||||
const startupSandboxPolicy = startupSandboxEnvironment
|
||||
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
|
||||
: undefined;
|
||||
const buildThreadLifecycleParams = () =>
|
||||
const buildThreadLifecycleParams = (signal: AbortSignal) =>
|
||||
({
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
params: params.buildAttemptParams(),
|
||||
agentId: params.sessionAgentId,
|
||||
cwd: startupExecutionCwd,
|
||||
@@ -266,7 +301,7 @@ export async function startCodexAttemptThread(params: {
|
||||
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
contextEngineProjection: params.contextEngineProjection,
|
||||
signal: params.signal,
|
||||
signal,
|
||||
pluginThreadConfig: pluginThreadConfigRequired
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -276,9 +311,9 @@ export async function startCodexAttemptThread(params: {
|
||||
buildCodexPluginThreadConfig({
|
||||
pluginConfig: pluginThreadConfigPluginConfig,
|
||||
request: (method, requestParams) =>
|
||||
startupClient.request(method, requestParams, {
|
||||
activeStartupClient.request(method, requestParams, {
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal: params.signal,
|
||||
signal,
|
||||
}),
|
||||
appCache: defaultCodexAppInventoryCache,
|
||||
appCacheKey: pluginAppCacheKey,
|
||||
@@ -287,22 +322,24 @@ export async function startCodexAttemptThread(params: {
|
||||
: undefined,
|
||||
}) satisfies Parameters<typeof startOrResumeThread>[0];
|
||||
try {
|
||||
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
|
||||
if (params.signal.aborted) {
|
||||
const startupThread = await startOrResumeThread(
|
||||
buildThreadLifecycleParams(startupAbandonController.signal),
|
||||
);
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
startupSandboxEnvironmentAcquired = false;
|
||||
startupAttemptSucceeded = true;
|
||||
return {
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
thread: startupThread,
|
||||
sandboxEnvironment: startupSandboxEnvironment,
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
executionCwd: startupExecutionCwd,
|
||||
sandboxPolicy: startupSandboxPolicy,
|
||||
restartContextEngineCodexThread: () =>
|
||||
startOrResumeThread(buildThreadLifecycleParams()),
|
||||
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
|
||||
};
|
||||
} catch (error) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
@@ -312,12 +349,32 @@ export async function startCodexAttemptThread(params: {
|
||||
releaseStartupResourcesOnTimeout = undefined;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
startupAttemptError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
if (!startupAttemptSucceeded) {
|
||||
if (releaseSharedClientLease === startupClientLease) {
|
||||
releaseSharedClientLease = undefined;
|
||||
}
|
||||
startupClientLease?.();
|
||||
if (startupAbandoned || params.signal.aborted) {
|
||||
if (startupClientForAbandonedRequestCleanup === startupClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
await closeAbandonedStartupClient(startupClient);
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error: startupAttemptError,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
if (startupClientForAbandonedRequestCleanup === startupClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
await evictFailedStartupClient(startupClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -375,26 +432,115 @@ export async function startCodexAttemptThread(params: {
|
||||
releaseSharedClientLease,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
params.signal.aborted ||
|
||||
if (params.signal.aborted || shouldClearSharedClientAfterStartupAbandon(error)) {
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(error) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
clearSharedCodexAppServerClientIfCurrent(startupClientForAbandonedRequestCleanup);
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await evictFailedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
params.signal.removeEventListener("abort", abandonStartupAcquire);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAbandonedStartupClient(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
|
||||
const closeable = client as {
|
||||
close?: CodexAppServerClient["close"];
|
||||
closeAndWait?: CodexAppServerClient["closeAndWait"];
|
||||
};
|
||||
if (typeof closeable.closeAndWait === "function") {
|
||||
await closeable.closeAndWait();
|
||||
return;
|
||||
}
|
||||
closeable.close?.();
|
||||
}
|
||||
|
||||
async function evictFailedStartupClient(client: CodexAppServerClient | undefined): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === "codex app-server startup timed out" ||
|
||||
error.message === "codex app-server startup aborted")
|
||||
);
|
||||
}
|
||||
|
||||
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === "codex app-server startup timed out" ||
|
||||
error.message === "codex app-server startup aborted" ||
|
||||
error.message.endsWith(" timed out"))
|
||||
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -11,6 +11,10 @@ export type CodexAppServerClientFactory = (
|
||||
authProfileId?: string,
|
||||
agentDir?: string,
|
||||
config?: AuthProfileOrderConfig,
|
||||
options?: {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
},
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
|
||||
@@ -25,9 +29,17 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
getSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
}),
|
||||
);
|
||||
|
||||
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
@@ -35,7 +47,15 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
getLeasedSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
type EmbeddedAgentCompactResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
defaultCodexAppServerClientFactory,
|
||||
defaultLeasedCodexAppServerClientFactory,
|
||||
type CodexAppServerClientFactory,
|
||||
} from "./client-factory.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
|
||||
import { readCodexAppServerBinding } from "./session-binding.js";
|
||||
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
|
||||
const warnedIgnoredCompactionOverrides = new Set<string>();
|
||||
|
||||
@@ -177,7 +178,8 @@ async function compactCodexNativeThread(
|
||||
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
|
||||
}
|
||||
|
||||
const clientFactory = options.clientFactory ?? defaultCodexAppServerClientFactory;
|
||||
const shouldReleaseDefaultLease = !options.clientFactory;
|
||||
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
|
||||
const client = await clientFactory(
|
||||
appServer.start,
|
||||
requestedAuthProfileId ?? binding.authProfileId,
|
||||
@@ -211,6 +213,10 @@ async function compactCodexNativeThread(
|
||||
compacted: false,
|
||||
reason: formatCompactionError(error),
|
||||
};
|
||||
} finally {
|
||||
if (shouldReleaseDefaultLease) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
const resultDetails: JsonObject = {
|
||||
backend: "codex-app-server",
|
||||
|
||||
@@ -189,6 +189,28 @@ describe("shared Codex app-server client", () => {
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps a pending shared app-server alive when another acquire still owns startup", async () => {
|
||||
const harness = createClientHarness();
|
||||
const abandonController = new AbortController();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const abandonedAcquire = getSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
abandonSignal: abandonController.signal,
|
||||
});
|
||||
const activeAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
abandonController.abort();
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(abandonedAcquire).resolves.toBe(harness.client);
|
||||
await expect(activeAcquire).resolves.toBe(harness.client);
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not wait for isolated initialize after a timeout closes the client", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
@@ -18,6 +18,7 @@ type SharedCodexAppServerClientEntry = {
|
||||
client?: CodexAppServerClient;
|
||||
promise?: Promise<CodexAppServerClient>;
|
||||
activeLeases: number;
|
||||
pendingAcquires: number;
|
||||
closeWhenIdle: boolean;
|
||||
};
|
||||
|
||||
@@ -48,6 +49,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
const clients = keyedState.clients as Map<string, SharedCodexAppServerClientEntry>;
|
||||
for (const entry of clients.values()) {
|
||||
entry.activeLeases ??= 0;
|
||||
entry.pendingAcquires ??= 0;
|
||||
entry.closeWhenIdle ??= false;
|
||||
}
|
||||
const nextState: SharedCodexAppServerClientState = {
|
||||
@@ -66,6 +68,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
client: legacyState.client,
|
||||
promise: legacyState.promise,
|
||||
activeLeases: 0,
|
||||
pendingAcquires: 0,
|
||||
closeWhenIdle: false,
|
||||
});
|
||||
legacyState.client?.addCloseHandler((closedClient) =>
|
||||
@@ -102,6 +105,8 @@ type CodexAppServerClientOptions = {
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
type ResolvedCodexAppServerClientStartContext = {
|
||||
@@ -194,11 +199,27 @@ async function acquireSharedCodexAppServerClient(
|
||||
});
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const entry = getOrCreateSharedClientEntry(state, key);
|
||||
const releasePendingAcquire = retainPendingSharedClientAcquire(entry);
|
||||
let cleanupAbandonSignal: (() => void) | undefined;
|
||||
if (options?.abandonSignal) {
|
||||
const abandon = () => {
|
||||
// Release this acquire before cleanup checks ownership; only other
|
||||
// pending callers should keep the startup client alive.
|
||||
releasePendingAcquire();
|
||||
closeSharedClientEntryIfUnclaimed(key, entry);
|
||||
};
|
||||
options.abandonSignal.addEventListener("abort", abandon, { once: true });
|
||||
cleanupAbandonSignal = () => options.abandonSignal?.removeEventListener("abort", abandon);
|
||||
if (options.abandonSignal.aborted) {
|
||||
abandon();
|
||||
}
|
||||
}
|
||||
const sharedPromise =
|
||||
entry.promise ??
|
||||
(entry.promise = (async () => {
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
entry.client = client;
|
||||
options?.onStartedClient?.(client);
|
||||
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
|
||||
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
|
||||
try {
|
||||
@@ -233,6 +254,9 @@ async function acquireSharedCodexAppServerClient(
|
||||
clearSharedClientEntry(key, currentEntry);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
cleanupAbandonSignal?.();
|
||||
releasePendingAcquire();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +410,7 @@ function getOrCreateSharedClientEntry(
|
||||
): SharedCodexAppServerClientEntry {
|
||||
let entry = state.clients.get(key);
|
||||
if (!entry) {
|
||||
entry = { activeLeases: 0, closeWhenIdle: false };
|
||||
entry = { activeLeases: 0, pendingAcquires: 0, closeWhenIdle: false };
|
||||
state.clients.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
@@ -409,6 +433,39 @@ function clearSharedClientEntryIfCurrent(key: string, client: CodexAppServerClie
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSharedCodexAppServerClientIfCurrentAndUnclaimed(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): { found: boolean; closed: boolean; activeLeases: number; pendingAcquires: number } {
|
||||
if (!client) {
|
||||
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
for (const [key, entry] of state.clients) {
|
||||
if (entry.client === client) {
|
||||
return {
|
||||
found: true,
|
||||
closed: closeSharedClientEntryIfUnclaimed(key, entry),
|
||||
activeLeases: entry.activeLeases,
|
||||
pendingAcquires: entry.pendingAcquires,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
|
||||
}
|
||||
|
||||
function retainPendingSharedClientAcquire(entry: SharedCodexAppServerClientEntry): () => void {
|
||||
let released = false;
|
||||
entry.pendingAcquires += 1;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
entry.pendingAcquires = Math.max(0, entry.pendingAcquires - 1);
|
||||
closeRetiredSharedClientEntryIfIdle(entry);
|
||||
};
|
||||
}
|
||||
|
||||
function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () => void {
|
||||
let released = false;
|
||||
entry.activeLeases += 1;
|
||||
@@ -423,7 +480,12 @@ function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () =>
|
||||
}
|
||||
|
||||
function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEntry): boolean {
|
||||
if (!entry.closeWhenIdle || entry.activeLeases > 0 || !entry.client) {
|
||||
if (
|
||||
!entry.closeWhenIdle ||
|
||||
entry.activeLeases > 0 ||
|
||||
entry.pendingAcquires > 0 ||
|
||||
!entry.client
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const client = entry.client;
|
||||
@@ -433,6 +495,22 @@ function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEn
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeSharedClientEntryIfUnclaimed(
|
||||
key: string,
|
||||
entry: SharedCodexAppServerClientEntry,
|
||||
): boolean {
|
||||
if (entry.activeLeases > 0 || entry.pendingAcquires > 0) {
|
||||
return false;
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
if (state.clients.get(key) !== entry) {
|
||||
return false;
|
||||
}
|
||||
state.clients.delete(key);
|
||||
entry.client?.close();
|
||||
return Boolean(entry.client);
|
||||
}
|
||||
|
||||
function collectSharedClients(state: SharedCodexAppServerClientState): CodexAppServerClient[] {
|
||||
return [
|
||||
...new Set(
|
||||
|
||||
@@ -22,6 +22,15 @@ export function createClientHarness() {
|
||||
const stdout = new PassThrough();
|
||||
const writes: string[] = [];
|
||||
let stdinDestroyed = false;
|
||||
let exitEmitted = false;
|
||||
let emitProcessExit: () => void = () => undefined;
|
||||
type HarnessProcess = EventEmitter & {
|
||||
stdin: Writable;
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
killed: boolean;
|
||||
kill: (signal?: NodeJS.Signals) => unknown;
|
||||
};
|
||||
const stdin = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
writes.push(chunk.toString());
|
||||
@@ -31,17 +40,25 @@ export function createClientHarness() {
|
||||
const destroyStdin = stdin.destroy.bind(stdin);
|
||||
stdin.destroy = ((error?: Error) => {
|
||||
stdinDestroyed = true;
|
||||
return destroyStdin(error);
|
||||
const result = destroyStdin(error);
|
||||
if (!exitEmitted) {
|
||||
exitEmitted = true;
|
||||
queueMicrotask(emitProcessExit);
|
||||
}
|
||||
return result;
|
||||
}) as typeof stdin.destroy;
|
||||
const process = Object.assign(new EventEmitter(), {
|
||||
const process: HarnessProcess = Object.assign(new EventEmitter(), {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr: new PassThrough(),
|
||||
killed: false,
|
||||
kill: vi.fn(() => {
|
||||
kill: vi.fn((_signal?: NodeJS.Signals) => {
|
||||
process.killed = true;
|
||||
}),
|
||||
});
|
||||
emitProcessExit = () => {
|
||||
process.emit("exit", 0, null);
|
||||
};
|
||||
const client = CodexAppServerClient.fromTransportForTests(process);
|
||||
return {
|
||||
client,
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { EmbeddedBlockChunker, formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
mergeChannelProgressDraftLine,
|
||||
normalizeChannelProgressDraftLineIdentity,
|
||||
resolveChannelProgressDraftMaxLineChars,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
createChannelProgressDraftCompositor,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingProgressCommentary,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
@@ -79,86 +72,48 @@ export function createDiscordDraftPreviewController(params: {
|
||||
let draftText = "";
|
||||
let hasStreamedMessage = false;
|
||||
let finalizedViaPreviewMessage = false;
|
||||
let finalReplyStarted = false;
|
||||
let finalReplyDelivered = false;
|
||||
const previewToolProgressEnabled =
|
||||
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
|
||||
const commentaryProgressEnabled =
|
||||
Boolean(draftStream) && resolveChannelStreamingProgressCommentary(params.discordConfig);
|
||||
const suppressDefaultToolProgressMessages =
|
||||
Boolean(draftStream) &&
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.discordConfig, {
|
||||
draftStreamActive: true,
|
||||
previewToolProgressEnabled,
|
||||
});
|
||||
let previewToolProgressSuppressed = false;
|
||||
let previewToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
|
||||
let reasoningProgressRawText = "";
|
||||
let lastReasoningProgressLine: string | undefined;
|
||||
const progressSeed = `${params.accountId}:${params.deliverChannelId}`;
|
||||
|
||||
const renderProgressDraft = async (options?: { flush?: boolean }) => {
|
||||
if (!draftStream || discordStreamMode !== "progress") {
|
||||
return;
|
||||
}
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: params.discordConfig,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
if (!previewText || previewText === lastPartialText) {
|
||||
return;
|
||||
}
|
||||
lastPartialText = previewText;
|
||||
draftText = previewText;
|
||||
hasStreamedMessage = true;
|
||||
draftChunker?.reset();
|
||||
draftStream.update(previewText);
|
||||
if (options?.flush) {
|
||||
await draftStream.flush();
|
||||
}
|
||||
};
|
||||
|
||||
const progressDraftGate = createChannelProgressDraftGate({
|
||||
onStart: () => renderProgressDraft({ flush: true }),
|
||||
const progressDraft = createChannelProgressDraftCompositor({
|
||||
entry: params.discordConfig,
|
||||
mode: discordStreamMode,
|
||||
active: Boolean(draftStream),
|
||||
seed: progressSeed,
|
||||
update: async (previewText, options) => {
|
||||
lastPartialText = previewText;
|
||||
draftText = previewText;
|
||||
hasStreamedMessage = true;
|
||||
draftChunker?.reset();
|
||||
draftStream?.update(previewText);
|
||||
if (options?.flush) {
|
||||
await draftStream?.flush();
|
||||
}
|
||||
},
|
||||
deleteCurrent: async () => {
|
||||
lastPartialText = "";
|
||||
draftText = "";
|
||||
hasStreamedMessage = false;
|
||||
if (draftStream?.messageId()) {
|
||||
await draftStream.deleteCurrentMessage();
|
||||
}
|
||||
},
|
||||
isEmptyLine: isEmptyDiscordProgressLine,
|
||||
shouldStartNow: shouldStartDiscordProgressDraftNow,
|
||||
});
|
||||
|
||||
const clearProgressDraftLine = async (lineId: string) => {
|
||||
const nextLines = previewToolProgressLines.filter(
|
||||
(line) => typeof line !== "object" || line.id?.trim() !== lineId,
|
||||
);
|
||||
if (nextLines.length === previewToolProgressLines.length) {
|
||||
return;
|
||||
}
|
||||
previewToolProgressLines = nextLines;
|
||||
if (!progressDraftGate.hasStarted) {
|
||||
return;
|
||||
}
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: params.discordConfig,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
if (previewText) {
|
||||
await renderProgressDraft();
|
||||
return;
|
||||
}
|
||||
lastPartialText = "";
|
||||
draftText = "";
|
||||
hasStreamedMessage = false;
|
||||
if (draftStream?.messageId()) {
|
||||
await draftStream.deleteCurrentMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const resetProgressState = () => {
|
||||
lastPartialText = "";
|
||||
draftText = "";
|
||||
draftChunker?.reset();
|
||||
previewToolProgressSuppressed = false;
|
||||
previewToolProgressLines = [];
|
||||
reasoningProgressRawText = "";
|
||||
lastReasoningProgressLine = undefined;
|
||||
progressDraft.reset();
|
||||
};
|
||||
|
||||
const forceNewMessageIfNeeded = () => {
|
||||
@@ -172,22 +127,23 @@ export function createDiscordDraftPreviewController(params: {
|
||||
return {
|
||||
draftStream,
|
||||
previewToolProgressEnabled,
|
||||
commentaryProgressEnabled,
|
||||
commentaryProgressEnabled: progressDraft.commentaryProgressEnabled,
|
||||
suppressDefaultToolProgressMessages,
|
||||
get isProgressMode() {
|
||||
return discordStreamMode === "progress";
|
||||
},
|
||||
get hasProgressDraftStarted() {
|
||||
return progressDraftGate.hasStarted;
|
||||
return progressDraft.hasStarted;
|
||||
},
|
||||
get finalizedViaPreviewMessage() {
|
||||
return finalizedViaPreviewMessage;
|
||||
},
|
||||
markFinalReplyStarted() {
|
||||
finalReplyStarted = true;
|
||||
progressDraft.markFinalReplyStarted();
|
||||
},
|
||||
markFinalReplyDelivered() {
|
||||
finalReplyDelivered = true;
|
||||
progressDraft.markFinalReplyDelivered();
|
||||
},
|
||||
markPreviewFinalized() {
|
||||
finalizedViaPreviewMessage = true;
|
||||
@@ -197,149 +153,19 @@ export function createDiscordDraftPreviewController(params: {
|
||||
if (!draftStream || discordStreamMode !== "progress") {
|
||||
return;
|
||||
}
|
||||
await progressDraftGate.startNow();
|
||||
await progressDraft.start();
|
||||
},
|
||||
async pushToolProgress(
|
||||
line?: string | ChannelProgressDraftLine,
|
||||
options?: { toolName?: string },
|
||||
) {
|
||||
if (!draftStream) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyStarted || finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
options?.toolName !== undefined &&
|
||||
!isChannelProgressDraftWorkToolName(options.toolName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isEmptyDiscordProgressLine(line)) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeChannelProgressDraftLineIdentity(line);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const progressLine: string | ChannelProgressDraftLine =
|
||||
typeof line === "object" && line !== undefined ? line : normalized;
|
||||
if (discordStreamMode !== "progress") {
|
||||
if (!previewToolProgressEnabled || previewToolProgressSuppressed) {
|
||||
return;
|
||||
}
|
||||
const nextLines = mergeChannelProgressDraftLine(previewToolProgressLines, progressLine, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
});
|
||||
if (nextLines === previewToolProgressLines) {
|
||||
return;
|
||||
}
|
||||
previewToolProgressLines = nextLines;
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: params.discordConfig,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
lastPartialText = previewText;
|
||||
draftText = previewText;
|
||||
hasStreamedMessage = true;
|
||||
draftChunker?.reset();
|
||||
draftStream.update(previewText);
|
||||
return;
|
||||
}
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
|
||||
previewToolProgressLines = mergeChannelProgressDraftLine(
|
||||
previewToolProgressLines,
|
||||
progressLine,
|
||||
{
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
},
|
||||
);
|
||||
}
|
||||
const alreadyStarted = progressDraftGate.hasStarted;
|
||||
let progressActive;
|
||||
if (shouldStartDiscordProgressDraftNow(line)) {
|
||||
await progressDraftGate.startNow();
|
||||
progressActive = progressDraftGate.hasStarted;
|
||||
} else {
|
||||
progressActive = await progressDraftGate.noteWork();
|
||||
}
|
||||
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
}
|
||||
await progressDraft.pushToolProgress(line, options);
|
||||
},
|
||||
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
|
||||
if (!draftStream || discordStreamMode !== "progress" || !text) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
reasoningProgressRawText = mergeReasoningProgressText(reasoningProgressRawText, text, {
|
||||
snapshot: options?.snapshot === true,
|
||||
});
|
||||
const normalized = normalizeReasoningProgressLine(reasoningProgressRawText);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const displayLine = formatReasoningProgressDisplayLine(
|
||||
normalized,
|
||||
resolveChannelProgressDraftMaxLineChars(params.discordConfig),
|
||||
);
|
||||
if (!displayLine) {
|
||||
return;
|
||||
}
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
|
||||
const priorIndex =
|
||||
lastReasoningProgressLine === undefined
|
||||
? -1
|
||||
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
|
||||
if (priorIndex >= 0) {
|
||||
previewToolProgressLines = [...previewToolProgressLines];
|
||||
previewToolProgressLines[priorIndex] = displayLine;
|
||||
} else {
|
||||
previewToolProgressLines = [...previewToolProgressLines, displayLine].slice(
|
||||
-resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
);
|
||||
}
|
||||
lastReasoningProgressLine = displayLine;
|
||||
}
|
||||
const progressActive = await progressDraftGate.noteWork();
|
||||
if (progressActive && progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
}
|
||||
await progressDraft.pushReasoningProgress(text, options);
|
||||
},
|
||||
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
|
||||
if (!draftStream || discordStreamMode !== "progress" || !commentaryProgressEnabled) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyStarted || finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
const itemId = options?.itemId?.trim();
|
||||
if (!text && !itemId) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeCommentaryProgressText(text ?? "");
|
||||
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
|
||||
if (!normalized) {
|
||||
if (lineId) {
|
||||
await clearProgressDraftLine(lineId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const line: ChannelProgressDraftLine = {
|
||||
id: lineId,
|
||||
kind: "item",
|
||||
text: normalized,
|
||||
label: "Commentary",
|
||||
prefix: false,
|
||||
};
|
||||
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
});
|
||||
await progressDraftGate.startNow();
|
||||
await renderProgressDraft();
|
||||
await progressDraft.pushCommentaryProgress(text, options);
|
||||
},
|
||||
resolvePreviewFinalText(text?: string) {
|
||||
if (typeof text !== "string") {
|
||||
@@ -390,8 +216,7 @@ export function createDiscordDraftPreviewController(params: {
|
||||
if (discordStreamMode === "progress") {
|
||||
return;
|
||||
}
|
||||
previewToolProgressSuppressed = true;
|
||||
previewToolProgressLines = [];
|
||||
progressDraft.suppress();
|
||||
hasStreamedMessage = true;
|
||||
if (discordStreamMode === "partial") {
|
||||
if (
|
||||
@@ -457,7 +282,7 @@ export function createDiscordDraftPreviewController(params: {
|
||||
},
|
||||
async cleanup() {
|
||||
try {
|
||||
progressDraftGate.cancel();
|
||||
progressDraft.cancel();
|
||||
if (!finalReplyDelivered) {
|
||||
await draftStream?.discardPending();
|
||||
}
|
||||
@@ -471,106 +296,6 @@ export function createDiscordDraftPreviewController(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressLine(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
|
||||
"",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressInput(text: string): string {
|
||||
const normalized = normalizeReasoningProgressLine(text);
|
||||
const italic = normalized.match(/^_(.*)_$/u);
|
||||
return (italic?.[1] ?? normalized).trim();
|
||||
}
|
||||
|
||||
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalizedText = normalizeReasoningProgressInput(text);
|
||||
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
|
||||
if (!formatted) {
|
||||
return "";
|
||||
}
|
||||
if (Array.from(formatted).length <= maxChars) {
|
||||
return formatted;
|
||||
}
|
||||
const italic = formatted.match(/^_(.*)_$/u);
|
||||
if (!italic) {
|
||||
return compactReasoningProgressDisplayLine(formatted, maxChars);
|
||||
}
|
||||
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
|
||||
return body ? `_${body}_` : "";
|
||||
}
|
||||
|
||||
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
const chars = Array.from(normalized);
|
||||
if (chars.length <= maxChars) {
|
||||
return normalized;
|
||||
}
|
||||
if (maxChars <= 1) {
|
||||
return "…";
|
||||
}
|
||||
const head = chars
|
||||
.slice(0, maxChars - 1)
|
||||
.join("")
|
||||
.trimEnd();
|
||||
const boundary = head.search(/\s+\S*$/u);
|
||||
if (boundary > Math.floor(maxChars * 0.6)) {
|
||||
return `${head.slice(0, boundary).trimEnd()}…`;
|
||||
}
|
||||
return `${head}…`;
|
||||
}
|
||||
|
||||
function normalizeCommentaryProgressText(text: string): string {
|
||||
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
|
||||
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
|
||||
return "";
|
||||
}
|
||||
return cleaned
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => `_${line}_`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isSilentCommentaryProgressText(text: string): boolean {
|
||||
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
|
||||
return /^NO_REPLY$/iu.test(normalized);
|
||||
}
|
||||
|
||||
function mergeReasoningProgressText(
|
||||
current: string,
|
||||
incoming: string,
|
||||
options?: { snapshot?: boolean },
|
||||
): string {
|
||||
if (!current) {
|
||||
return incoming;
|
||||
}
|
||||
const normalizedCurrent = normalizeReasoningProgressLine(current);
|
||||
const normalizedIncoming = normalizeReasoningProgressLine(incoming);
|
||||
if (!normalizedIncoming || normalizedIncoming === normalizedCurrent) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
options?.snapshot === true ||
|
||||
isReasoningSnapshotText(incoming) ||
|
||||
normalizedIncoming.startsWith(normalizedCurrent)
|
||||
) {
|
||||
return incoming;
|
||||
}
|
||||
return `${current}${incoming}`;
|
||||
}
|
||||
|
||||
function isReasoningSnapshotText(text: string): boolean {
|
||||
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {
|
||||
if (!line || typeof line === "string") {
|
||||
return false;
|
||||
|
||||
@@ -2504,7 +2504,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers tool warning finals when no recovered reply is available", async () => {
|
||||
it("suppresses pure tool warning finals when no recovered reply is available", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply(createNonTerminalToolWarningPayload());
|
||||
@@ -2519,18 +2519,10 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
expect(editMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers tool warning finals when the recovered reply fails to send", async () => {
|
||||
it("suppresses tool warning finals when the recovered reply fails to send", async () => {
|
||||
deliverDiscordReply.mockRejectedValueOnce(new Error("send failed"));
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({ text: "delivery failed" });
|
||||
@@ -2549,21 +2541,13 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(2);
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [{ text: "delivery failed" }],
|
||||
});
|
||||
expect(deliverDiscordReply.mock.calls[1]?.[0]).toMatchObject({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mutating tool warning finals after successful-looking replies", async () => {
|
||||
it("suppresses mutating tool warning finals after successful-looking replies", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({ text: "Done." });
|
||||
@@ -2582,15 +2566,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
expectPreviewEditContent("Done.");
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `write file (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses reasoning payload delivery to Discord", async () => {
|
||||
|
||||
@@ -66,6 +66,7 @@ import { createDiscordDraftPreviewController } from "./message-handler.draft-pre
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import { resolveForwardedMediaList, resolveMediaList } from "./message-utils.js";
|
||||
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
|
||||
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
|
||||
import {
|
||||
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
|
||||
@@ -111,7 +112,10 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
|
||||
return !resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||
}
|
||||
|
||||
type DiscordReplySkipReason = "aborted before delivery" | "reasoning payload";
|
||||
type DiscordReplySkipReason =
|
||||
| "aborted before delivery"
|
||||
| "reasoning payload"
|
||||
| "internal-only payload";
|
||||
|
||||
export function formatDiscordReplySkip(params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
@@ -621,7 +625,7 @@ async function processDiscordMessageInner(
|
||||
) => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
// Surface so operators don't chase missing replies when an abort
|
||||
// drops a model-produced text payload (see PR for the incident).
|
||||
// drops a model-produced text payload.
|
||||
logVerbose(
|
||||
formatDiscordReplySkip({
|
||||
kind: info.kind,
|
||||
@@ -669,10 +673,24 @@ async function processDiscordMessageInner(
|
||||
})
|
||||
: payload.text;
|
||||
const effectivePayload = finalText !== payload.text ? { ...payload, text: finalText } : payload;
|
||||
const [deliverablePayload] = sanitizeDiscordFrontChannelReplyPayloads([effectivePayload], {
|
||||
kind: info.kind,
|
||||
});
|
||||
if (!deliverablePayload) {
|
||||
logVerbose(
|
||||
formatDiscordReplySkip({
|
||||
kind: info.kind,
|
||||
reason: "internal-only payload",
|
||||
target: deliverTarget,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
}),
|
||||
);
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
const draftStream = draftPreview.draftStream;
|
||||
if (draftStream && draftPreview.isProgressMode && info.kind === "block") {
|
||||
const reply = resolveSendableOutboundReplyParts(effectivePayload);
|
||||
if (!reply.hasMedia && !payload.isError) {
|
||||
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
|
||||
if (!reply.hasMedia && !deliverablePayload.isError) {
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
}
|
||||
@@ -680,22 +698,22 @@ async function processDiscordMessageInner(
|
||||
draftStream &&
|
||||
isFinal &&
|
||||
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted) &&
|
||||
!payload.isError;
|
||||
!deliverablePayload.isError;
|
||||
if (shouldFinalizeDraftPreview) {
|
||||
const reply = resolveSendableOutboundReplyParts(effectivePayload);
|
||||
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
|
||||
const hasMedia = reply.hasMedia;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(effectivePayload);
|
||||
const previewSourceText = finalText ?? ttsSupplement?.spokenText;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(deliverablePayload);
|
||||
const previewSourceText = deliverablePayload.text ?? ttsSupplement?.spokenText;
|
||||
const previewFinalText = draftPreview.resolvePreviewFinalText(previewSourceText);
|
||||
const previewReplyToId = replyReference.peek();
|
||||
const hasExplicitReplyDirective =
|
||||
Boolean(effectivePayload.replyToTag || effectivePayload.replyToCurrent) ||
|
||||
Boolean(deliverablePayload.replyToTag || deliverablePayload.replyToCurrent) ||
|
||||
(typeof previewSourceText === "string" &&
|
||||
/\[\[\s*reply_to(?:_current|\s*:)/i.test(previewSourceText));
|
||||
|
||||
const result = await deliverWithFinalizableLivePreviewAdapter({
|
||||
kind: info.kind,
|
||||
payload: effectivePayload,
|
||||
payload: deliverablePayload,
|
||||
adapter: defineFinalizableLivePreviewAdapter({
|
||||
draft: {
|
||||
flush: () => draftPreview.flush(),
|
||||
@@ -710,7 +728,7 @@ async function processDiscordMessageInner(
|
||||
(hasMedia && !ttsSupplement) ||
|
||||
typeof previewFinalText !== "string" ||
|
||||
hasExplicitReplyDirective ||
|
||||
payload.isError
|
||||
deliverablePayload.isError
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -747,7 +765,7 @@ async function processDiscordMessageInner(
|
||||
replyReference.markSent();
|
||||
},
|
||||
buildSupplementalPayload: () =>
|
||||
ttsSupplement ? buildTtsSupplementMediaPayload(effectivePayload) : undefined,
|
||||
ttsSupplement ? buildTtsSupplementMediaPayload(deliverablePayload) : undefined,
|
||||
deliverSupplemental: async (supplementalPayload) => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
return false;
|
||||
@@ -794,9 +812,9 @@ async function processDiscordMessageInner(
|
||||
const fallbackPayload =
|
||||
ttsSupplement &&
|
||||
ttsSupplement.visibleTextAlreadyDelivered !== true &&
|
||||
!effectivePayload.text?.trim()
|
||||
? { ...effectivePayload, text: ttsSupplement.spokenText }
|
||||
: effectivePayload;
|
||||
!deliverablePayload.text?.trim()
|
||||
? { ...deliverablePayload, text: ttsSupplement.spokenText }
|
||||
: deliverablePayload;
|
||||
const replyToId = replyReference.use();
|
||||
notifyFinalReplyStart();
|
||||
await deliverDiscordReply({
|
||||
@@ -849,7 +867,7 @@ async function processDiscordMessageInner(
|
||||
}
|
||||
await deliverDiscordReply({
|
||||
cfg,
|
||||
replies: [effectivePayload],
|
||||
replies: [deliverablePayload],
|
||||
target: deliverTarget,
|
||||
token,
|
||||
accountId,
|
||||
@@ -867,7 +885,7 @@ async function processDiscordMessageInner(
|
||||
kind: info.kind,
|
||||
});
|
||||
replyReference.markSent();
|
||||
if (isFinal && payload.isError !== true) {
|
||||
if (isFinal && deliverablePayload.isError !== true) {
|
||||
markUserFacingFinalDelivered();
|
||||
}
|
||||
return { visibleReplySent: true };
|
||||
|
||||
@@ -176,6 +176,33 @@ describe("deliverDiscordReply", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("strips assistant scaffolding from explicit tool progress payloads", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
{
|
||||
text: [
|
||||
"<think>private reasoning</think>",
|
||||
'<tool_call>{"name":"x"}</tool_call>',
|
||||
"🛠️ run git status",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
target: "channel:101",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
cfg,
|
||||
textLimit: 2000,
|
||||
kind: "tool",
|
||||
});
|
||||
|
||||
expect(sendDurableMessageBatchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloads: [{ text: "🛠️ run git status" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips internal execution trace lines at the final Discord send boundary", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
@@ -183,6 +210,7 @@ describe("deliverDiscordReply", () => {
|
||||
text: [
|
||||
"📊 Session Status: current",
|
||||
"🛠️ run git status",
|
||||
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
"🛠️ `gh pr view`",
|
||||
"🛠️ `docker compose up`",
|
||||
"🛠️ elevated · `cd /tmp && pnpm test`",
|
||||
@@ -204,6 +232,26 @@ describe("deliverDiscordReply", () => {
|
||||
expect(firstDeliverParams().payloads).toEqual([{ text: "Visible reply." }]);
|
||||
});
|
||||
|
||||
it("drops pure internal tool failure warnings at the final Discord send boundary", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
target: "channel:101",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
cfg,
|
||||
textLimit: 2000,
|
||||
kind: "final",
|
||||
});
|
||||
|
||||
expect(sendDurableMessageBatchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips serialized tool call blocks at the final Discord send boundary", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import {
|
||||
sanitizeAssistantVisibleText,
|
||||
sanitizeAssistantVisibleTextWithProfile,
|
||||
} from "openclaw/plugin-sdk/text-chunking";
|
||||
import { stripPlainTextToolCallBlocks } from "openclaw/plugin-sdk/tool-payload";
|
||||
|
||||
const DISCORD_INTERNAL_TRACE_LINE_RE =
|
||||
/^(?:>\s*)?(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i;
|
||||
const DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE =
|
||||
/^(?:>\s*)?🛠️\s*(?:(?:(?:elevated|pty)\b\s*(?:·|,)\s*)+)?(?:`{1,2}\s*\S|(?:run|check|fetch|pull|push|view|show|list|switch|create|merge|rebase|stage|restore|reset|stash|search|find|print|copy|move|remove|install|start|cd|git|pnpm|npm|yarn|bun|node|python|python3|bash|sh)\b)/i;
|
||||
const DISCORD_INTERNAL_CHANNEL_LINE_RE =
|
||||
/^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i;
|
||||
/^(?:>\s*)?(?:analysis|commentary|thinking|reasoning)\s*[:=]/i;
|
||||
|
||||
function hasNonEmptyRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(
|
||||
@@ -36,7 +35,11 @@ function hasNonTextReplyPayloadContent(payload: ReplyPayload): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function stripDiscordInternalTraceLines(text: string): string {
|
||||
function collapseExcessBlankLines(text: string): string {
|
||||
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
function stripDiscordInternalChannelLines(text: string): string {
|
||||
let inFence = false;
|
||||
const kept: string[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
@@ -45,31 +48,20 @@ function stripDiscordInternalTraceLines(text: string): string {
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
if (!inFence) {
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
DISCORD_INTERNAL_TRACE_LINE_RE.test(trimmed) ||
|
||||
DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE.test(trimmed) ||
|
||||
DISCORD_INTERNAL_CHANNEL_LINE_RE.test(trimmed)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!inFence && DISCORD_INTERNAL_CHANNEL_LINE_RE.test(line.trim())) {
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function collapseExcessBlankLines(text: string): string {
|
||||
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
export function sanitizeDiscordFrontChannelText(text: string): string {
|
||||
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(text);
|
||||
const withoutAssistantScaffolding = sanitizeAssistantVisibleText(withoutToolCallBlocks);
|
||||
const withoutResidualToolCallBlocks = stripPlainTextToolCallBlocks(withoutAssistantScaffolding);
|
||||
const withoutTraceLines = stripDiscordInternalTraceLines(withoutResidualToolCallBlocks);
|
||||
return collapseExcessBlankLines(withoutTraceLines).trim();
|
||||
const withoutChannelLines = stripDiscordInternalChannelLines(withoutResidualToolCallBlocks);
|
||||
return collapseExcessBlankLines(withoutChannelLines).trim();
|
||||
}
|
||||
|
||||
export function sanitizeDiscordFrontChannelReplyPayloads(
|
||||
@@ -82,7 +74,9 @@ export function sanitizeDiscordFrontChannelReplyPayloads(
|
||||
const safeText =
|
||||
typeof payload.text === "string"
|
||||
? preserveVerboseToolProgress
|
||||
? collapseExcessBlankLines(sanitizeAssistantVisibleText(payload.text)).trim()
|
||||
? collapseExcessBlankLines(
|
||||
sanitizeAssistantVisibleTextWithProfile(payload.text, "tool-progress"),
|
||||
).trim()
|
||||
: sanitizeDiscordFrontChannelText(payload.text)
|
||||
: payload.text;
|
||||
const nextPayload =
|
||||
|
||||
@@ -661,7 +661,6 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
["tools settings array", { tools: { settings: [] } }, "oc://policy.jsonc/tools/settings"],
|
||||
["tools entries object", { tools: { entries: {} } }, "oc://policy.jsonc/tools/entries"],
|
||||
["tools profiles array", { tools: { profiles: [] } }, "oc://policy.jsonc/tools/profiles"],
|
||||
|
||||
[
|
||||
"tools profiles allow string",
|
||||
{ tools: { profiles: { allow: "coding" } } },
|
||||
@@ -994,6 +993,182 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported policy keys across policy namespaces", async () => {
|
||||
const cases: readonly {
|
||||
readonly label: string;
|
||||
readonly policy: unknown;
|
||||
readonly target: string;
|
||||
}[] = [
|
||||
{ label: "top-level", policy: { channel: {} }, target: "oc://policy.jsonc/channel" },
|
||||
{
|
||||
label: "tools top-level",
|
||||
policy: { tools: { execPolicy: { allowHosts: ["sandbox"] } } },
|
||||
target: "oc://policy.jsonc/tools/execPolicy",
|
||||
},
|
||||
{
|
||||
label: "tools settings",
|
||||
policy: { tools: { settings: {} } },
|
||||
target: "oc://policy.jsonc/tools/settings",
|
||||
},
|
||||
{
|
||||
label: "tools entries",
|
||||
policy: { tools: { entries: [] } },
|
||||
target: "oc://policy.jsonc/tools/entries",
|
||||
},
|
||||
{
|
||||
label: "tools profile",
|
||||
policy: { tools: { profiles: { deny: ["full"] } } },
|
||||
target: "oc://policy.jsonc/tools/profiles/deny",
|
||||
},
|
||||
{
|
||||
label: "tools exec",
|
||||
policy: { tools: { exec: { allowShells: ["bash"] } } },
|
||||
target: "oc://policy.jsonc/tools/exec/allowShells",
|
||||
},
|
||||
{
|
||||
label: "tools fs",
|
||||
policy: { tools: { fs: { allowOutsideWorkspace: true } } },
|
||||
target: "oc://policy.jsonc/tools/fs/allowOutsideWorkspace",
|
||||
},
|
||||
{
|
||||
label: "tools alsoAllow",
|
||||
policy: { tools: { alsoAllow: { denied: ["exec"] } } },
|
||||
target: "oc://policy.jsonc/tools/alsoAllow/denied",
|
||||
},
|
||||
{
|
||||
label: "channels",
|
||||
policy: { channels: { allowRules: [] } },
|
||||
target: "oc://policy.jsonc/channels/allowRules",
|
||||
},
|
||||
{
|
||||
label: "channel deny rule",
|
||||
policy: { channels: { denyRules: [{ when: { provider: "telegram" }, action: "deny" }] } },
|
||||
target: "oc://policy.jsonc/channels/denyRules/#0/action",
|
||||
},
|
||||
{
|
||||
label: "channel deny selector",
|
||||
policy: {
|
||||
channels: { denyRules: [{ when: { provider: "telegram", channel: "stable" } }] },
|
||||
},
|
||||
target: "oc://policy.jsonc/channels/denyRules/#0/when/channel",
|
||||
},
|
||||
{
|
||||
label: "ingress top-level",
|
||||
policy: { ingress: { directMessages: {} } },
|
||||
target: "oc://policy.jsonc/ingress/directMessages",
|
||||
},
|
||||
{
|
||||
label: "ingress session",
|
||||
policy: { ingress: { session: { requiredScope: "per-channel-peer" } } },
|
||||
target: "oc://policy.jsonc/ingress/session/requiredScope",
|
||||
},
|
||||
{
|
||||
label: "ingress channels",
|
||||
policy: { ingress: { channels: { allowOpenGroups: false } } },
|
||||
target: "oc://policy.jsonc/ingress/channels/allowOpenGroups",
|
||||
},
|
||||
{ label: "mcp", policy: { mcp: { clients: {} } }, target: "oc://policy.jsonc/mcp/clients" },
|
||||
{
|
||||
label: "mcp servers",
|
||||
policy: { mcp: { servers: { require: ["docs"] } } },
|
||||
target: "oc://policy.jsonc/mcp/servers/require",
|
||||
},
|
||||
{
|
||||
label: "models",
|
||||
policy: { models: { modelRefs: {} } },
|
||||
target: "oc://policy.jsonc/models/modelRefs",
|
||||
},
|
||||
{
|
||||
label: "models providers",
|
||||
policy: { models: { providers: { require: ["openai"] } } },
|
||||
target: "oc://policy.jsonc/models/providers/require",
|
||||
},
|
||||
{
|
||||
label: "network",
|
||||
policy: { network: { publicNetwork: {} } },
|
||||
target: "oc://policy.jsonc/network/publicNetwork",
|
||||
},
|
||||
{
|
||||
label: "network privateNetwork",
|
||||
policy: { network: { privateNetwork: { deny: true } } },
|
||||
target: "oc://policy.jsonc/network/privateNetwork/deny",
|
||||
},
|
||||
{
|
||||
label: "gateway top-level",
|
||||
policy: { gateway: { bind: { allowNonLoopback: false } } },
|
||||
target: "oc://policy.jsonc/gateway/bind",
|
||||
},
|
||||
{
|
||||
label: "gateway exposure",
|
||||
policy: { gateway: { exposure: { allowPublicBind: false } } },
|
||||
target: "oc://policy.jsonc/gateway/exposure/allowPublicBind",
|
||||
},
|
||||
{
|
||||
label: "gateway auth",
|
||||
policy: { gateway: { auth: { allowDisabled: false } } },
|
||||
target: "oc://policy.jsonc/gateway/auth/allowDisabled",
|
||||
},
|
||||
{
|
||||
label: "agents",
|
||||
policy: { agents: { tools: {} } },
|
||||
target: "oc://policy.jsonc/agents/tools",
|
||||
},
|
||||
{
|
||||
label: "agents workspace",
|
||||
policy: { agents: { workspace: { requireReadOnly: true } } },
|
||||
target: "oc://policy.jsonc/agents/workspace/requireReadOnly",
|
||||
},
|
||||
{
|
||||
label: "dataHandling",
|
||||
policy: { dataHandling: { logs: { requireRedaction: true } } },
|
||||
target: "oc://policy.jsonc/dataHandling/logs",
|
||||
},
|
||||
{
|
||||
label: "dataHandling nested",
|
||||
policy: { dataHandling: { telemetry: { allowCaptureContent: false } } },
|
||||
target: "oc://policy.jsonc/dataHandling/telemetry/allowCaptureContent",
|
||||
},
|
||||
{
|
||||
label: "secrets",
|
||||
policy: { secrets: { requireVault: true } },
|
||||
target: "oc://policy.jsonc/secrets/requireVault",
|
||||
},
|
||||
{
|
||||
label: "auth",
|
||||
policy: { auth: { providers: {} } },
|
||||
target: "oc://policy.jsonc/auth/providers",
|
||||
},
|
||||
{
|
||||
label: "auth profiles",
|
||||
policy: { auth: { profiles: { requireProvider: true } } },
|
||||
target: "oc://policy.jsonc/auth/profiles/requireProvider",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const configPath = join(workspaceDir, `${testCase.label.replaceAll(" ", "-")}.jsonc`);
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify(testCase.policy),
|
||||
"utf-8",
|
||||
);
|
||||
clearHealthChecksForTest();
|
||||
resetPolicyDoctorChecksForTest();
|
||||
|
||||
const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy()));
|
||||
|
||||
expect(result.findings, testCase.label).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "policy/policy-jsonc-invalid",
|
||||
severity: "error",
|
||||
path: "policy.jsonc",
|
||||
target: testCase.target,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a policy hash mismatch when expectedHash is configured", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
|
||||
@@ -523,6 +523,28 @@ const KNOWN_SENSITIVITY_LEVELS = ["public", "internal", "confidential", "restric
|
||||
const SUPPORTED_TOOL_METADATA = ["risk", "sensitivity", "owner"] as const;
|
||||
const SUPPORTED_AUTH_PROFILE_METADATA = ["provider", "mode"] as const;
|
||||
const SUPPORTED_AUTH_PROFILE_MODES = ["api_key", "aws-sdk", "oauth", "token"] as const;
|
||||
const SUPPORTED_POLICY_SECTIONS = [
|
||||
"auth",
|
||||
"agents",
|
||||
"channels",
|
||||
"dataHandling",
|
||||
"gateway",
|
||||
"ingress",
|
||||
"mcp",
|
||||
"models",
|
||||
"network",
|
||||
"sandbox",
|
||||
"scopes",
|
||||
"secrets",
|
||||
"tools",
|
||||
] as const;
|
||||
const SUPPORTED_GATEWAY_POLICY_SECTIONS = [
|
||||
"auth",
|
||||
"controlUi",
|
||||
"exposure",
|
||||
"http",
|
||||
"remote",
|
||||
] as const;
|
||||
const SUPPORTED_GATEWAY_HTTP_ENDPOINTS = ["chatCompletions", "responses"] as const;
|
||||
const SUPPORTED_DM_POLICIES = ["pairing", "allowlist", "open", "disabled"] as const;
|
||||
const SUPPORTED_DM_SCOPES = [
|
||||
@@ -1623,6 +1645,17 @@ export function policyContainerShapeFindings(
|
||||
),
|
||||
];
|
||||
}
|
||||
const unsupportedTopLevel = unsupportedPolicyKey(policy, SUPPORTED_POLICY_SECTIONS);
|
||||
if (unsupportedTopLevel !== undefined) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/${ocPathSegment(unsupportedTopLevel)}`,
|
||||
`${policyPath} ${unsupportedTopLevel} is not a supported policy section.`,
|
||||
`Remove ${unsupportedTopLevel} or use a supported policy section.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
if (policy.tools !== undefined && !isRecord(policy.tools)) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
@@ -1634,26 +1667,6 @@ export function policyContainerShapeFindings(
|
||||
];
|
||||
}
|
||||
if (isRecord(policy.tools)) {
|
||||
if (policy.tools.settings !== undefined && !isRecord(policy.tools.settings)) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/tools/settings`,
|
||||
`${policyPath} tools.settings must be an object.`,
|
||||
`Fix ${policyPath} so tools.settings is an object.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
if (policy.tools.entries !== undefined && !Array.isArray(policy.tools.entries)) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/tools/entries`,
|
||||
`${policyPath} tools.entries must be an array.`,
|
||||
`Fix ${policyPath} so tools.entries is an array.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
const postureFinding = toolPosturePolicyShapeFinding(policy.tools, {
|
||||
policyDocName,
|
||||
policyPath,
|
||||
@@ -1672,6 +1685,19 @@ export function policyContainerShapeFindings(
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isRecord(policy.channels)) {
|
||||
const unsupportedChannelKey = unsupportedPolicyKey(policy.channels, ["denyRules"]);
|
||||
if (unsupportedChannelKey !== undefined) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/channels/${ocPathSegment(unsupportedChannelKey)}`,
|
||||
`${policyPath} channels.${unsupportedChannelKey} is not supported in channel policy.`,
|
||||
`Remove channels.${unsupportedChannelKey} or use channels.denyRules.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (policy.mcp !== undefined && !isRecord(policy.mcp)) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
@@ -1682,6 +1708,19 @@ export function policyContainerShapeFindings(
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isRecord(policy.mcp)) {
|
||||
const unsupportedMcpKey = unsupportedPolicyKey(policy.mcp, ["servers"]);
|
||||
if (unsupportedMcpKey !== undefined) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/mcp/${ocPathSegment(unsupportedMcpKey)}`,
|
||||
`${policyPath} mcp.${unsupportedMcpKey} is not supported in MCP policy.`,
|
||||
`Remove mcp.${unsupportedMcpKey} or use mcp.servers.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (policy.dataHandling !== undefined && !isRecord(policy.dataHandling)) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
@@ -1714,6 +1753,19 @@ export function policyContainerShapeFindings(
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isRecord(policy.models)) {
|
||||
const unsupportedModelsKey = unsupportedPolicyKey(policy.models, ["providers"]);
|
||||
if (unsupportedModelsKey !== undefined) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/models/${ocPathSegment(unsupportedModelsKey)}`,
|
||||
`${policyPath} models.${unsupportedModelsKey} is not supported in model policy.`,
|
||||
`Remove models.${unsupportedModelsKey} or use models.providers.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (isRecord(policy.models)) {
|
||||
const finding = policyStringArrayShapeFinding(policy.models.providers, {
|
||||
property: "models.providers",
|
||||
@@ -1737,6 +1789,17 @@ export function policyContainerShapeFindings(
|
||||
];
|
||||
}
|
||||
if (isRecord(policy.network)) {
|
||||
const unsupportedNetworkKey = unsupportedPolicyKey(policy.network, ["privateNetwork"]);
|
||||
if (unsupportedNetworkKey !== undefined) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/network/${ocPathSegment(unsupportedNetworkKey)}`,
|
||||
`${policyPath} network.${unsupportedNetworkKey} is not supported in network policy.`,
|
||||
`Remove network.${unsupportedNetworkKey} or use network.privateNetwork.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
if (policy.network.privateNetwork !== undefined && !isRecord(policy.network.privateNetwork)) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
@@ -1747,6 +1810,21 @@ export function policyContainerShapeFindings(
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isRecord(policy.network.privateNetwork)) {
|
||||
const unsupportedPrivateNetworkKey = unsupportedPolicyKey(policy.network.privateNetwork, [
|
||||
"allow",
|
||||
]);
|
||||
if (unsupportedPrivateNetworkKey !== undefined) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/network/privateNetwork/${ocPathSegment(unsupportedPrivateNetworkKey)}`,
|
||||
`${policyPath} network.privateNetwork.${unsupportedPrivateNetworkKey} is not supported in network policy.`,
|
||||
`Remove network.privateNetwork.${unsupportedPrivateNetworkKey} or use network.privateNetwork.allow.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (
|
||||
isRecord(policy.network.privateNetwork) &&
|
||||
policy.network.privateNetwork.allow !== undefined &&
|
||||
@@ -1772,6 +1850,23 @@ export function policyContainerShapeFindings(
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isRecord(policy.secrets)) {
|
||||
const unsupportedSecretsKey = unsupportedPolicyKey(policy.secrets, [
|
||||
"allowInsecureProviders",
|
||||
"denySources",
|
||||
"requireManagedProviders",
|
||||
]);
|
||||
if (unsupportedSecretsKey !== undefined) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/secrets/${ocPathSegment(unsupportedSecretsKey)}`,
|
||||
`${policyPath} secrets.${unsupportedSecretsKey} is not supported in secrets policy.`,
|
||||
`Remove secrets.${unsupportedSecretsKey} or use a supported secrets policy rule.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (policy.auth !== undefined && !isRecord(policy.auth)) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
@@ -1782,6 +1877,19 @@ export function policyContainerShapeFindings(
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isRecord(policy.auth)) {
|
||||
const unsupportedAuthKey = unsupportedPolicyKey(policy.auth, ["profiles"]);
|
||||
if (unsupportedAuthKey !== undefined) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/auth/${ocPathSegment(unsupportedAuthKey)}`,
|
||||
`${policyPath} auth.${unsupportedAuthKey} is not supported in auth policy.`,
|
||||
`Remove auth.${unsupportedAuthKey} or use auth.profiles.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
if (
|
||||
isRecord(policy.auth) &&
|
||||
policy.auth.profiles !== undefined &&
|
||||
@@ -1796,6 +1904,22 @@ export function policyContainerShapeFindings(
|
||||
),
|
||||
];
|
||||
}
|
||||
if (isRecord(policy.auth) && isRecord(policy.auth.profiles)) {
|
||||
const unsupportedProfilesKey = unsupportedPolicyKey(policy.auth.profiles, [
|
||||
"allowModes",
|
||||
"requireMetadata",
|
||||
]);
|
||||
if (unsupportedProfilesKey !== undefined) {
|
||||
return [
|
||||
policyShapeFinding(
|
||||
policyPath,
|
||||
`oc://${policyDocName}/auth/profiles/${ocPathSegment(unsupportedProfilesKey)}`,
|
||||
`${policyPath} auth.profiles.${unsupportedProfilesKey} is not supported in auth profile policy.`,
|
||||
`Remove auth.profiles.${unsupportedProfilesKey} or use a supported auth profile policy rule.`,
|
||||
),
|
||||
];
|
||||
}
|
||||
}
|
||||
const sandboxFinding = sandboxPolicyShapeFinding(policy.sandbox, {
|
||||
policyDocName,
|
||||
policyPath,
|
||||
@@ -1867,6 +1991,15 @@ function ingressPolicyShapeFinding(
|
||||
`Move session ingress rules to top-level ingress; scoped ingress currently supports ingress.channels.*.`,
|
||||
);
|
||||
}
|
||||
const unsupportedIngressKey = unsupportedPolicyKey(value, ["channels", "session"]);
|
||||
if (unsupportedIngressKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedIngressKey)}`,
|
||||
`${params.policyPath} ${propertyPrefix}.${unsupportedIngressKey} is not supported in ingress policy.`,
|
||||
`Remove ${propertyPrefix}.${unsupportedIngressKey} or use ingress.session or ingress.channels.`,
|
||||
);
|
||||
}
|
||||
for (const section of ["session", "channels"] as const) {
|
||||
if (value[section] !== undefined && !isRecord(value[section])) {
|
||||
return policyShapeFinding(
|
||||
@@ -1878,6 +2011,15 @@ function ingressPolicyShapeFinding(
|
||||
}
|
||||
}
|
||||
const session = isRecord(value.session) ? value.session : {};
|
||||
const unsupportedSessionKey = unsupportedPolicyKey(session, ["requireDmScope"]);
|
||||
if (unsupportedSessionKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${targetPrefix}/session/${ocPathSegment(unsupportedSessionKey)}`,
|
||||
`${params.policyPath} ${propertyPrefix}.session.${unsupportedSessionKey} is not supported in ingress policy.`,
|
||||
`Remove ${propertyPrefix}.session.${unsupportedSessionKey} or use ${propertyPrefix}.session.requireDmScope.`,
|
||||
);
|
||||
}
|
||||
if (
|
||||
session.requireDmScope !== undefined &&
|
||||
!SUPPORTED_DM_SCOPES.includes(session.requireDmScope as (typeof SUPPORTED_DM_SCOPES)[number])
|
||||
@@ -1890,6 +2032,19 @@ function ingressPolicyShapeFinding(
|
||||
);
|
||||
}
|
||||
const channels = isRecord(value.channels) ? value.channels : {};
|
||||
const unsupportedChannelsKey = unsupportedPolicyKey(channels, [
|
||||
"allowDmPolicies",
|
||||
"denyOpenGroups",
|
||||
"requireMentionInGroups",
|
||||
]);
|
||||
if (unsupportedChannelsKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${targetPrefix}/channels/${ocPathSegment(unsupportedChannelsKey)}`,
|
||||
`${params.policyPath} ${propertyPrefix}.channels.${unsupportedChannelsKey} is not supported in ingress policy.`,
|
||||
`Remove ${propertyPrefix}.channels.${unsupportedChannelsKey} or use a supported ingress channel policy rule.`,
|
||||
);
|
||||
}
|
||||
const allowDmPoliciesFinding = policyStringArrayPropertyShapeFinding(channels.allowDmPolicies, {
|
||||
allowed: SUPPORTED_DM_POLICIES,
|
||||
policyDocName: params.policyDocName,
|
||||
@@ -1932,6 +2087,15 @@ function agentsPolicyShapeFinding(
|
||||
`Fix ${params.policyPath} so agents is an object.`,
|
||||
);
|
||||
}
|
||||
const unsupportedAgentsKey = unsupportedPolicyKey(value, ["workspace"]);
|
||||
if (unsupportedAgentsKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/agents/${ocPathSegment(unsupportedAgentsKey)}`,
|
||||
`${params.policyPath} agents.${unsupportedAgentsKey} is not supported in agents policy.`,
|
||||
`Remove agents.${unsupportedAgentsKey} or use agents.workspace.`,
|
||||
);
|
||||
}
|
||||
const workspaceFinding = agentWorkspacePolicyShapeFinding(value.workspace, {
|
||||
policyDocName: params.policyDocName,
|
||||
policyPath: params.policyPath,
|
||||
@@ -2309,6 +2473,15 @@ function agentWorkspacePolicyShapeFinding(
|
||||
`Fix ${params.policyPath} so ${params.propertyPrefix} is an object.`,
|
||||
);
|
||||
}
|
||||
const unsupportedWorkspaceKey = unsupportedPolicyKey(value, ["allowedAccess", "denyTools"]);
|
||||
if (unsupportedWorkspaceKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${params.targetPrefix}/${ocPathSegment(unsupportedWorkspaceKey)}`,
|
||||
`${params.policyPath} ${params.propertyPrefix}.${unsupportedWorkspaceKey} is not supported in agent workspace policy.`,
|
||||
`Remove ${params.propertyPrefix}.${unsupportedWorkspaceKey} or use a supported agent workspace policy rule.`,
|
||||
);
|
||||
}
|
||||
const allowedAccess = value.allowedAccess;
|
||||
if (allowedAccess !== undefined && !Array.isArray(allowedAccess)) {
|
||||
return policyShapeFinding(
|
||||
@@ -2371,6 +2544,24 @@ function toolPosturePolicyShapeFinding(
|
||||
): HealthFinding | undefined {
|
||||
const targetPrefix = params.targetPrefix ?? "tools";
|
||||
const propertyPrefix = params.propertyPrefix ?? "tools";
|
||||
const allowedTopLevel = [
|
||||
"alsoAllow",
|
||||
"denyTools",
|
||||
"elevated",
|
||||
"exec",
|
||||
"fs",
|
||||
"profiles",
|
||||
"requireMetadata",
|
||||
];
|
||||
const unsupportedTopLevel = unsupportedPolicyKey(tools, allowedTopLevel);
|
||||
if (unsupportedTopLevel !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${targetPrefix}/${ocPathSegment(unsupportedTopLevel)}`,
|
||||
`${params.policyPath} ${propertyPrefix}.${unsupportedTopLevel} is not supported in tools policy.`,
|
||||
`Remove ${propertyPrefix}.${unsupportedTopLevel} or use a supported tools policy rule.`,
|
||||
);
|
||||
}
|
||||
for (const section of ["profiles", "fs", "exec", "elevated", "alsoAllow"] as const) {
|
||||
if (tools[section] !== undefined && !isRecord(tools[section])) {
|
||||
return policyShapeFinding(
|
||||
@@ -2383,6 +2574,15 @@ function toolPosturePolicyShapeFinding(
|
||||
}
|
||||
|
||||
const profiles = isRecord(tools.profiles) ? tools.profiles : {};
|
||||
const unsupportedProfileKey = unsupportedPolicyKey(profiles, ["allow"]);
|
||||
if (unsupportedProfileKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${targetPrefix}/profiles/${ocPathSegment(unsupportedProfileKey)}`,
|
||||
`${params.policyPath} ${propertyPrefix}.profiles.${unsupportedProfileKey} is not supported in tools policy.`,
|
||||
`Remove ${propertyPrefix}.profiles.${unsupportedProfileKey} or use ${propertyPrefix}.profiles.allow.`,
|
||||
);
|
||||
}
|
||||
const profileAllowFinding = policyStringArrayPropertyShapeFinding(profiles.allow, {
|
||||
allowed: SUPPORTED_TOOL_PROFILES,
|
||||
policyDocName: params.policyDocName,
|
||||
@@ -2396,6 +2596,15 @@ function toolPosturePolicyShapeFinding(
|
||||
}
|
||||
|
||||
const fs = isRecord(tools.fs) ? tools.fs : {};
|
||||
const unsupportedFsKey = unsupportedPolicyKey(fs, ["requireWorkspaceOnly"]);
|
||||
if (unsupportedFsKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${targetPrefix}/fs/${ocPathSegment(unsupportedFsKey)}`,
|
||||
`${params.policyPath} ${propertyPrefix}.fs.${unsupportedFsKey} is not supported in tools policy.`,
|
||||
`Remove ${propertyPrefix}.fs.${unsupportedFsKey} or use ${propertyPrefix}.fs.requireWorkspaceOnly.`,
|
||||
);
|
||||
}
|
||||
if (fs.requireWorkspaceOnly !== undefined && typeof fs.requireWorkspaceOnly !== "boolean") {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
@@ -2406,6 +2615,19 @@ function toolPosturePolicyShapeFinding(
|
||||
}
|
||||
|
||||
const exec = isRecord(tools.exec) ? tools.exec : {};
|
||||
const unsupportedExecKey = unsupportedPolicyKey(exec, [
|
||||
"allowHosts",
|
||||
"allowSecurity",
|
||||
"requireAsk",
|
||||
]);
|
||||
if (unsupportedExecKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${targetPrefix}/exec/${ocPathSegment(unsupportedExecKey)}`,
|
||||
`${params.policyPath} ${propertyPrefix}.exec.${unsupportedExecKey} is not supported in tools policy.`,
|
||||
`Remove ${propertyPrefix}.exec.${unsupportedExecKey} or use a supported tools exec policy rule.`,
|
||||
);
|
||||
}
|
||||
const execLists = [
|
||||
["allowSecurity", SUPPORTED_TOOL_EXEC_SECURITY, "exec security mode"],
|
||||
["requireAsk", SUPPORTED_TOOL_EXEC_ASK, "exec ask mode"],
|
||||
@@ -2426,6 +2648,15 @@ function toolPosturePolicyShapeFinding(
|
||||
}
|
||||
|
||||
const elevated = isRecord(tools.elevated) ? tools.elevated : {};
|
||||
const unsupportedElevatedKey = unsupportedPolicyKey(elevated, ["allow"]);
|
||||
if (unsupportedElevatedKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${targetPrefix}/elevated/${ocPathSegment(unsupportedElevatedKey)}`,
|
||||
`${params.policyPath} ${propertyPrefix}.elevated.${unsupportedElevatedKey} is not supported in tools policy.`,
|
||||
`Remove ${propertyPrefix}.elevated.${unsupportedElevatedKey} or use ${propertyPrefix}.elevated.allow.`,
|
||||
);
|
||||
}
|
||||
if (elevated.allow !== undefined && typeof elevated.allow !== "boolean") {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
@@ -2436,6 +2667,15 @@ function toolPosturePolicyShapeFinding(
|
||||
}
|
||||
|
||||
const alsoAllow = isRecord(tools.alsoAllow) ? tools.alsoAllow : {};
|
||||
const unsupportedAlsoAllowKey = unsupportedPolicyKey(alsoAllow, ["expected"]);
|
||||
if (unsupportedAlsoAllowKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${targetPrefix}/alsoAllow/${ocPathSegment(unsupportedAlsoAllowKey)}`,
|
||||
`${params.policyPath} ${propertyPrefix}.alsoAllow.${unsupportedAlsoAllowKey} is not supported in tools policy.`,
|
||||
`Remove ${propertyPrefix}.alsoAllow.${unsupportedAlsoAllowKey} or use ${propertyPrefix}.alsoAllow.expected.`,
|
||||
);
|
||||
}
|
||||
const alsoAllowExpectedFinding = policyStringArrayPropertyShapeFinding(alsoAllow.expected, {
|
||||
policyDocName: params.policyDocName,
|
||||
policyPath: params.policyPath,
|
||||
@@ -2608,12 +2848,38 @@ function gatewayPolicyShapeFinding(
|
||||
);
|
||||
}
|
||||
}
|
||||
const unsupportedGatewayKey = unsupportedPolicyKey(value, SUPPORTED_GATEWAY_POLICY_SECTIONS);
|
||||
if (unsupportedGatewayKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/gateway/${ocPathSegment(unsupportedGatewayKey)}`,
|
||||
`${params.policyPath} gateway.${unsupportedGatewayKey} is not supported in Gateway policy.`,
|
||||
`Remove gateway.${unsupportedGatewayKey} or use a supported Gateway policy section.`,
|
||||
);
|
||||
}
|
||||
|
||||
const exposure = isRecord(value.exposure) ? value.exposure : {};
|
||||
const auth = isRecord(value.auth) ? value.auth : {};
|
||||
const controlUi = isRecord(value.controlUi) ? value.controlUi : {};
|
||||
const remote = isRecord(value.remote) ? value.remote : {};
|
||||
const http = isRecord(value.http) ? value.http : {};
|
||||
for (const [section, sectionValue, allowedKeys] of [
|
||||
["exposure", exposure, ["allowNonLoopbackBind", "allowTailscaleFunnel"]],
|
||||
["auth", auth, ["requireAuth", "requireExplicitRateLimit"]],
|
||||
["controlUi", controlUi, ["allowInsecure"]],
|
||||
["remote", remote, ["allow"]],
|
||||
["http", http, ["denyEndpoints", "requireUrlAllowlists"]],
|
||||
] as const) {
|
||||
const unsupportedKey = unsupportedPolicyKey(sectionValue, allowedKeys);
|
||||
if (unsupportedKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/gateway/${section}/${ocPathSegment(unsupportedKey)}`,
|
||||
`${params.policyPath} gateway.${section}.${unsupportedKey} is not supported in Gateway policy.`,
|
||||
`Remove gateway.${section}.${unsupportedKey} or use a supported Gateway policy rule.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
const booleanRules = [
|
||||
[
|
||||
"gateway/exposure/allowNonLoopbackBind",
|
||||
@@ -2700,6 +2966,15 @@ function policyStringArrayShapeFinding(
|
||||
`Fix ${params.policyPath} so ${params.property} is an object.`,
|
||||
);
|
||||
}
|
||||
const unsupportedKey = unsupportedPolicyKey(value, ["allow", "deny"]);
|
||||
if (unsupportedKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${params.target}/${ocPathSegment(unsupportedKey)}`,
|
||||
`${params.policyPath} ${params.property}.${unsupportedKey} is not supported in policy.`,
|
||||
`Remove ${params.property}.${unsupportedKey} or use ${params.property}.allow or ${params.property}.deny.`,
|
||||
);
|
||||
}
|
||||
for (const key of ["allow", "deny"] as const) {
|
||||
const entries = value[key];
|
||||
if (entries === undefined) {
|
||||
@@ -2857,6 +3132,41 @@ function invalidChannelDenyRuleFindings(
|
||||
},
|
||||
];
|
||||
}
|
||||
for (const [index, rule] of policy.channels.denyRules.entries()) {
|
||||
if (!isRecord(rule)) {
|
||||
continue;
|
||||
}
|
||||
const unsupportedRuleKey = unsupportedPolicyKey(rule, ["id", "reason", "when"]);
|
||||
if (unsupportedRuleKey !== undefined) {
|
||||
return [
|
||||
{
|
||||
checkId: CHECK_IDS.policyInvalidFile,
|
||||
severity: "error",
|
||||
message: `${policyPath} channels.denyRules[${index}].${unsupportedRuleKey} is not supported in channel deny rules.`,
|
||||
source: "policy",
|
||||
path: policyPath,
|
||||
target: `oc://${policyDocName}/channels/denyRules/#${index}/${ocPathSegment(unsupportedRuleKey)}`,
|
||||
fixHint: `Remove channels.denyRules[${index}].${unsupportedRuleKey} or use id, when.provider, and reason.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (isRecord(rule.when)) {
|
||||
const unsupportedWhenKey = unsupportedPolicyKey(rule.when, ["provider"]);
|
||||
if (unsupportedWhenKey !== undefined) {
|
||||
return [
|
||||
{
|
||||
checkId: CHECK_IDS.policyInvalidFile,
|
||||
severity: "error",
|
||||
message: `${policyPath} channels.denyRules[${index}].when.${unsupportedWhenKey} is not supported in channel deny rules.`,
|
||||
source: "policy",
|
||||
path: policyPath,
|
||||
target: `oc://${policyDocName}/channels/denyRules/#${index}/when/${ocPathSegment(unsupportedWhenKey)}`,
|
||||
fixHint: `Remove channels.denyRules[${index}].when.${unsupportedWhenKey} or use when.provider.`,
|
||||
},
|
||||
];
|
||||
}
|
||||
}
|
||||
}
|
||||
const invalid = policy.channels.denyRules.findIndex((rule) => !isChannelDenyRule(rule));
|
||||
if (invalid < 0) {
|
||||
return [];
|
||||
@@ -4742,6 +5052,14 @@ function dataHandlingPolicyShapeFindings(
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
policySectionUnsupportedKeyFinding(policy.dataHandling, {
|
||||
policyPath,
|
||||
policyDocName,
|
||||
propertyPath: "dataHandling",
|
||||
targetPath: "dataHandling",
|
||||
sectionName: "data-handling",
|
||||
allowedKeys: ["memory", "retention", "sensitiveLogging", "telemetry"],
|
||||
}),
|
||||
dataHandlingSectionShapeFinding(policy.dataHandling, {
|
||||
policyPath,
|
||||
policyDocName,
|
||||
@@ -4801,6 +5119,29 @@ function dataHandlingPolicyShapeFindings(
|
||||
].filter((finding): finding is HealthFinding => finding !== undefined);
|
||||
}
|
||||
|
||||
function policySectionUnsupportedKeyFinding(
|
||||
value: Record<string, unknown>,
|
||||
params: {
|
||||
readonly policyPath: string;
|
||||
readonly policyDocName: string;
|
||||
readonly propertyPath: string;
|
||||
readonly targetPath: string;
|
||||
readonly sectionName: string;
|
||||
readonly allowedKeys: readonly string[];
|
||||
},
|
||||
): HealthFinding | undefined {
|
||||
const unsupportedKey = unsupportedPolicyKey(value, params.allowedKeys);
|
||||
if (unsupportedKey === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${params.targetPath}/${ocPathSegment(unsupportedKey)}`,
|
||||
`${params.policyPath} ${params.propertyPath}.${unsupportedKey} is not supported in ${params.sectionName} policy.`,
|
||||
`Remove ${params.propertyPath}.${unsupportedKey} or use a supported ${params.sectionName} policy rule.`,
|
||||
);
|
||||
}
|
||||
|
||||
function dataHandlingSectionShapeFinding(
|
||||
dataHandling: Record<string, unknown>,
|
||||
params: {
|
||||
@@ -4834,6 +5175,24 @@ function dataHandlingBooleanShapeFinding(
|
||||
},
|
||||
): HealthFinding | undefined {
|
||||
const value = getPolicyPath(dataHandling, params.path);
|
||||
if (isRecord(dataHandling) && typeof params.path[0] === "string") {
|
||||
const section = dataHandling[params.path[0]];
|
||||
if (isRecord(section) && typeof params.path[1] === "string") {
|
||||
const sectionPath = params.path.slice(0, -1).join(".");
|
||||
const unsupportedKey = unsupportedPolicyKey(section, [params.path[1]]);
|
||||
if (unsupportedKey !== undefined) {
|
||||
return policyShapeFinding(
|
||||
params.policyPath,
|
||||
`oc://${params.policyDocName}/${params.targetPath
|
||||
.split("/")
|
||||
.slice(0, -1)
|
||||
.join("/")}/${ocPathSegment(unsupportedKey)}`,
|
||||
`${params.policyPath} dataHandling.${sectionPath}.${unsupportedKey} is not supported in data-handling policy.`,
|
||||
`Remove dataHandling.${sectionPath}.${unsupportedKey} or use ${params.propertyPath}.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (value === undefined || typeof value === "boolean") {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -2317,6 +2317,81 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(draftStream.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("composes streamed reasoning with tool progress in Telegram progress drafts", async () => {
|
||||
const draftStream = createSequencedDraftStream(2001);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
|
||||
await replyOptions?.onReplyStart?.();
|
||||
await replyOptions?.onAssistantMessageStart?.();
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await replyOptions?.onReasoningStream?.({ text: "<think>Checking files</think>" });
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createReasoningStreamContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
});
|
||||
|
||||
expect(createTelegramDraftStream).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n`🛠️ Exec`\n• _Checking files_");
|
||||
});
|
||||
|
||||
it("renders configured Telegram commentary progress from preamble item events", async () => {
|
||||
const draftStream = createSequencedDraftStream(2001);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
|
||||
await replyOptions?.onReplyStart?.();
|
||||
await replyOptions?.onItemEvent?.({
|
||||
kind: "preamble",
|
||||
itemId: "preamble-1",
|
||||
progressText: "Checking recent context",
|
||||
});
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: { label: "Shelling", commentary: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n_Checking recent context_");
|
||||
});
|
||||
|
||||
it("suppresses Telegram preamble progress when commentary is disabled", async () => {
|
||||
const draftStream = createSequencedDraftStream(2001);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
|
||||
await replyOptions?.onReplyStart?.();
|
||||
await replyOptions?.onItemEvent?.({
|
||||
kind: "preamble",
|
||||
itemId: "preamble-1",
|
||||
progressText: "Checking recent context",
|
||||
});
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: { label: "Shelling" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(draftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Checking recent"));
|
||||
});
|
||||
|
||||
it("keeps the progress draft label when tool progress lines are hidden", async () => {
|
||||
const draftStream = createSequencedDraftStream(2001);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
|
||||
@@ -24,14 +24,10 @@ import {
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
buildChannelProgressDraftLineForEntry,
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
createChannelProgressDraftCompositor,
|
||||
formatChannelProgressDraftLine,
|
||||
formatChannelProgressDraftLineForEntry,
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
mergeChannelProgressDraftLine,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingPreviewNativeToolProgress,
|
||||
resolveChannelStreamingPreviewNativeToolProgressAllowFrom,
|
||||
@@ -408,6 +404,13 @@ function formatProgressAsMarkdownCode(text: string): string {
|
||||
return `\`${sanitizeProgressMarkdownText(clipped)}\``;
|
||||
}
|
||||
|
||||
function formatTelegramProgressLine(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.startsWith("_") && trimmed.endsWith("_")
|
||||
? trimmed
|
||||
: formatProgressAsMarkdownCode(text);
|
||||
}
|
||||
|
||||
function normalizeTelegramThreadId(value: unknown): number | undefined {
|
||||
return parseStrictPositiveInteger(value);
|
||||
}
|
||||
@@ -873,7 +876,10 @@ export const dispatchTelegramMessage = async ({
|
||||
!hasTelegramQuoteReply &&
|
||||
!accountBlockStreamingEnabled &&
|
||||
!forceBlockStreamingForReasoning;
|
||||
const canStreamReasoningDraft = !isRoomEvent && streamReasoningDraft;
|
||||
const streamReasoningInProgressDraft =
|
||||
streamReasoningDraft && streamMode === "progress" && canStreamAnswerDraft;
|
||||
const canStreamReasoningDraft =
|
||||
!isRoomEvent && streamReasoningDraft && !streamReasoningInProgressDraft;
|
||||
const draftReplyToMessageId =
|
||||
replyToMode !== "off" && typeof msg.message_id === "number"
|
||||
? (replyQuoteMessageId ?? msg.message_id)
|
||||
@@ -936,8 +942,6 @@ export const dispatchTelegramMessage = async ({
|
||||
log: logVerbose,
|
||||
})
|
||||
: undefined;
|
||||
let streamToolProgressSuppressed = false;
|
||||
let streamToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
|
||||
let lastAnswerPartialText = "";
|
||||
let activeAnswerDraftIsToolProgressOnly = false;
|
||||
function resetAnswerToolProgressDraft() {
|
||||
@@ -952,33 +956,25 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
activeAnswerDraftIsToolProgressOnly = true;
|
||||
}
|
||||
const renderProgressDraft = async (options?: { flush?: boolean }): Promise<boolean> => {
|
||||
if (!answerLane.stream || streamMode !== "progress") {
|
||||
return false;
|
||||
}
|
||||
const streamText = formatChannelProgressDraftText({
|
||||
entry: telegramCfg,
|
||||
lines: streamToolProgressLines,
|
||||
seed: progressSeed,
|
||||
formatLine: formatProgressAsMarkdownCode,
|
||||
});
|
||||
if (!streamText || streamText === answerLane.lastPartialText) {
|
||||
return false;
|
||||
}
|
||||
await prepareAnswerLaneForToolProgress();
|
||||
answerLane.lastPartialText = streamText;
|
||||
answerLane.hasStreamedMessage = true;
|
||||
answerLane.finalized = false;
|
||||
answerLane.stream.update(streamText);
|
||||
if (options?.flush) {
|
||||
await answerLane.stream.flush();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const progressDraftGate = createChannelProgressDraftGate({
|
||||
onStart: async () => {
|
||||
await renderProgressDraft({ flush: true });
|
||||
const progressDraft = createChannelProgressDraftCompositor({
|
||||
entry: telegramCfg,
|
||||
mode: streamMode,
|
||||
active: Boolean(answerLane.stream),
|
||||
seed: progressSeed,
|
||||
formatLine: formatTelegramProgressLine,
|
||||
update: async (streamText, options) => {
|
||||
await prepareAnswerLaneForToolProgress();
|
||||
answerLane.lastPartialText = streamText;
|
||||
answerLane.hasStreamedMessage = true;
|
||||
answerLane.finalized = false;
|
||||
answerLane.stream?.update(streamText);
|
||||
if (options?.flush) {
|
||||
await answerLane.stream?.flush();
|
||||
}
|
||||
},
|
||||
tryNativeUpdate: nativeToolProgressDraft
|
||||
? async (streamText) => await nativeToolProgressDraft.update(streamText)
|
||||
: undefined,
|
||||
});
|
||||
let finalAnswerDeliveryStarted = false;
|
||||
let finalAnswerDelivered = false;
|
||||
@@ -986,80 +982,37 @@ export const dispatchTelegramMessage = async ({
|
||||
line?: string | ChannelProgressDraftLine,
|
||||
options?: { toolName?: string; startImmediately?: boolean },
|
||||
) => {
|
||||
if (!answerLane.stream) {
|
||||
if (
|
||||
!answerLane.stream ||
|
||||
answerLane.finalized ||
|
||||
finalAnswerDeliveryStarted ||
|
||||
finalAnswerDelivered
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (answerLane.finalized || finalAnswerDeliveryStarted || finalAnswerDelivered) {
|
||||
return false;
|
||||
}
|
||||
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
|
||||
return false;
|
||||
}
|
||||
const rawText = typeof line === "string" ? line : line?.text;
|
||||
const normalized = sanitizeProgressMarkdownText(rawText?.replace(/\s+/g, " ").trim() ?? "");
|
||||
if (streamToolProgressSuppressed) {
|
||||
return false;
|
||||
}
|
||||
if (streamMode !== "progress" && !streamToolProgressEnabled) {
|
||||
return false;
|
||||
}
|
||||
const shouldUpdateProgressLines =
|
||||
streamToolProgressEnabled && !streamToolProgressSuppressed && Boolean(normalized);
|
||||
if (!shouldUpdateProgressLines && streamMode !== "progress") {
|
||||
return false;
|
||||
}
|
||||
const progressLine =
|
||||
typeof line === "object" && line !== undefined ? { ...line, text: normalized } : normalized;
|
||||
const nextLines = shouldUpdateProgressLines
|
||||
? mergeChannelProgressDraftLine(streamToolProgressLines, progressLine, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(telegramCfg),
|
||||
})
|
||||
: streamToolProgressLines;
|
||||
if (shouldUpdateProgressLines && nextLines === streamToolProgressLines) {
|
||||
return false;
|
||||
}
|
||||
if (nativeToolProgressDraft && shouldUpdateProgressLines) {
|
||||
const streamText = formatChannelProgressDraftText({
|
||||
entry: telegramCfg,
|
||||
lines: nextLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
if (streamText && (await nativeToolProgressDraft.update(streamText))) {
|
||||
streamToolProgressLines = nextLines;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (streamMode !== "progress") {
|
||||
streamToolProgressLines = nextLines;
|
||||
const streamText = formatChannelProgressDraftText({
|
||||
entry: telegramCfg,
|
||||
lines: streamToolProgressLines,
|
||||
seed: progressSeed,
|
||||
formatLine: formatProgressAsMarkdownCode,
|
||||
});
|
||||
await prepareAnswerLaneForToolProgress();
|
||||
answerLane.lastPartialText = streamText;
|
||||
answerLane.hasStreamedMessage = true;
|
||||
answerLane.finalized = false;
|
||||
answerLane.stream.update(streamText);
|
||||
return true;
|
||||
}
|
||||
streamToolProgressLines = nextLines;
|
||||
if (options?.startImmediately) {
|
||||
await progressDraftGate.startNow();
|
||||
if (progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
return true;
|
||||
}
|
||||
return progressDraftGate.hasStarted;
|
||||
}
|
||||
const alreadyStarted = progressDraftGate.hasStarted;
|
||||
const progressActive = await progressDraftGate.noteWork();
|
||||
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return await progressDraft.pushToolProgress(line, options);
|
||||
};
|
||||
const pushStreamReasoningProgress = async (payload: {
|
||||
text?: string;
|
||||
isReasoningSnapshot?: boolean;
|
||||
}) => {
|
||||
return await progressDraft.pushReasoningProgress(payload.text, {
|
||||
snapshot: payload.isReasoningSnapshot === true,
|
||||
});
|
||||
};
|
||||
const markProgressFinalStarted = () => {
|
||||
finalAnswerDeliveryStarted = true;
|
||||
progressDraft.markFinalReplyStarted();
|
||||
};
|
||||
const markProgressFinalDelivered = () => {
|
||||
finalAnswerDelivered = true;
|
||||
progressDraft.markFinalReplyDelivered();
|
||||
};
|
||||
const resetProgressDraftState = () => {
|
||||
progressDraft.reset();
|
||||
};
|
||||
const suppressProgressDraftState = () => {
|
||||
progressDraft.suppress();
|
||||
};
|
||||
let splitReasoningOnNextStream = false;
|
||||
let draftLaneEventQueue = Promise.resolve();
|
||||
@@ -1143,8 +1096,7 @@ export const dispatchTelegramMessage = async ({
|
||||
await answerLane.stream?.clear();
|
||||
answerLane.stream?.forceNewMessage();
|
||||
resetDraftLaneState(answerLane);
|
||||
streamToolProgressSuppressed = true;
|
||||
streamToolProgressLines = [];
|
||||
suppressProgressDraftState();
|
||||
return true;
|
||||
};
|
||||
const prepareAnswerLaneForText = async () => {
|
||||
@@ -1172,8 +1124,7 @@ export const dispatchTelegramMessage = async ({
|
||||
return;
|
||||
}
|
||||
resetAnswerToolProgressDraft();
|
||||
streamToolProgressSuppressed = true;
|
||||
streamToolProgressLines = [];
|
||||
suppressProgressDraftState();
|
||||
}
|
||||
lane.hasStreamedMessage = true;
|
||||
lane.finalized = false;
|
||||
@@ -1587,7 +1538,7 @@ export const dispatchTelegramMessage = async ({
|
||||
return { kind: "skipped" };
|
||||
}
|
||||
answerLane.finalized = true;
|
||||
finalAnswerDelivered = true;
|
||||
markProgressFinalDelivered();
|
||||
return { kind: "sent" };
|
||||
};
|
||||
const resolveTranscriptBackedFinalText = async (text: string): Promise<string> =>
|
||||
@@ -1702,7 +1653,7 @@ export const dispatchTelegramMessage = async ({
|
||||
const segments = split.segments;
|
||||
const reply = resolveSendableOutboundReplyParts(effectivePayload);
|
||||
if (info.kind === "final" && (reply.text.length > 0 || reply.hasMedia)) {
|
||||
finalAnswerDeliveryStarted = true;
|
||||
markProgressFinalStarted();
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
await enqueueDraftLaneEvent(async () => {});
|
||||
@@ -1734,7 +1685,7 @@ export const dispatchTelegramMessage = async ({
|
||||
buttons,
|
||||
});
|
||||
if (result.kind !== "skipped") {
|
||||
finalAnswerDelivered = true;
|
||||
markProgressFinalDelivered();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -1849,7 +1800,7 @@ export const dispatchTelegramMessage = async ({
|
||||
});
|
||||
}
|
||||
if (info.kind === "final" && delivered) {
|
||||
finalAnswerDelivered = true;
|
||||
markProgressFinalDelivered();
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
await flushBufferedFinalAnswer();
|
||||
@@ -1875,7 +1826,7 @@ export const dispatchTelegramMessage = async ({
|
||||
durable: info.kind === "final",
|
||||
});
|
||||
if (info.kind === "final" && delivered) {
|
||||
finalAnswerDelivered = true;
|
||||
markProgressFinalDelivered();
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
await flushBufferedFinalAnswer();
|
||||
@@ -1957,13 +1908,19 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
await ingestDraftLaneSegments(payload, true);
|
||||
})
|
||||
: undefined,
|
||||
: streamReasoningInProgressDraft
|
||||
? (payload) =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
await pushStreamReasoningProgress(payload);
|
||||
})
|
||||
: undefined,
|
||||
onAssistantMessageStart: answerLane.stream
|
||||
? () =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
reasoningStepState.resetForNextStep();
|
||||
streamToolProgressSuppressed = false;
|
||||
streamToolProgressLines = [];
|
||||
if (streamMode !== "progress") {
|
||||
resetProgressDraftState();
|
||||
}
|
||||
if (answerLane.finalized) {
|
||||
await rotateLaneForNewMessage(answerLane);
|
||||
}
|
||||
@@ -1973,8 +1930,7 @@ export const dispatchTelegramMessage = async ({
|
||||
? () =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
|
||||
streamToolProgressSuppressed = false;
|
||||
streamToolProgressLines = [];
|
||||
resetProgressDraftState();
|
||||
})
|
||||
: undefined,
|
||||
suppressDefaultToolProgressMessages:
|
||||
@@ -2002,6 +1958,12 @@ export const dispatchTelegramMessage = async ({
|
||||
await progressPromise;
|
||||
},
|
||||
onItemEvent: async (payload) => {
|
||||
if (payload.kind === "preamble") {
|
||||
await progressDraft.pushCommentaryProgress(payload.progressText, {
|
||||
itemId: payload.itemId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await pushStreamToolProgress(
|
||||
buildChannelProgressDraftLineForEntry(telegramCfg, {
|
||||
event: "item",
|
||||
@@ -2106,7 +2068,7 @@ export const dispatchTelegramMessage = async ({
|
||||
dispatchError = err;
|
||||
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
|
||||
} finally {
|
||||
progressDraftGate.cancel();
|
||||
progressDraft.cancel();
|
||||
await draftLaneEventQueue;
|
||||
nativeToolProgressDraft?.stop();
|
||||
const lanesToCleanup: Array<{ laneName: LaneName; lane: DraftLaneState }> = [
|
||||
|
||||
@@ -106,6 +106,22 @@ describe("telegram custom commands schema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts Telegram progress commentary config", () => {
|
||||
expectTelegramConfigValid({
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: { commentary: true },
|
||||
},
|
||||
accounts: {
|
||||
ops: {
|
||||
streaming: {
|
||||
progress: { commentary: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects removed DM thread reply policy keys", () => {
|
||||
expectTelegramConfigIssue({ dm: { threadReplies: "off" } }, "");
|
||||
expectTelegramConfigIssue(
|
||||
|
||||
@@ -109,6 +109,10 @@ export const telegramChannelConfigUiHints = {
|
||||
label: "Telegram Progress Command Text",
|
||||
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
|
||||
},
|
||||
"streaming.progress.commentary": {
|
||||
label: "Telegram Progress Commentary",
|
||||
help: "Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged.",
|
||||
},
|
||||
"retry.attempts": {
|
||||
label: "Telegram Retry Attempts",
|
||||
help: "Max retry attempts for outbound Telegram API calls (default: 3).",
|
||||
|
||||
@@ -760,6 +760,45 @@ describe("telegram message cache", () => {
|
||||
expect(context.map((entry) => entry.node.body)).not.toContain(staleInstruction);
|
||||
});
|
||||
|
||||
it("uses the current reset command as the session boundary", async () => {
|
||||
const cache = createTelegramMessageCache();
|
||||
const chat = { id: 7, type: "group", title: "Ops" } as const;
|
||||
await cache.record({
|
||||
accountId: "default",
|
||||
chatId: 7,
|
||||
msg: {
|
||||
chat,
|
||||
message_id: 100,
|
||||
date: 1736380800,
|
||||
text: "stale context",
|
||||
from: { id: 100, is_bot: false, first_name: "Requester" },
|
||||
} as Message,
|
||||
});
|
||||
await cache.record({
|
||||
accountId: "default",
|
||||
chatId: 7,
|
||||
msg: {
|
||||
chat,
|
||||
message_id: 101,
|
||||
date: 1736380860,
|
||||
text: "/new",
|
||||
from: { id: 101, is_bot: false, first_name: "Requester" },
|
||||
} as Message,
|
||||
});
|
||||
|
||||
const context = await buildTelegramConversationContext({
|
||||
cache,
|
||||
accountId: "default",
|
||||
chatId: 7,
|
||||
messageId: "101",
|
||||
replyChainNodes: [],
|
||||
recentLimit: 10,
|
||||
replyTargetWindowSize: 1,
|
||||
});
|
||||
|
||||
expect(context).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not select messages before the persisted session start when the reset command is absent", async () => {
|
||||
const cache = createTelegramMessageCache();
|
||||
const beforeSession = Date.parse("2026-05-10T12:40:00.000Z");
|
||||
|
||||
@@ -54,6 +54,13 @@ export type TelegramMessageCache = {
|
||||
before: number;
|
||||
after: number;
|
||||
}) => Promise<TelegramCachedMessageNode[]>;
|
||||
latestMatchingAtOrBefore: (params: {
|
||||
accountId: string;
|
||||
chatId: string | number;
|
||||
messageId?: string;
|
||||
threadId?: number;
|
||||
matches: (node: TelegramCachedMessageNode) => boolean;
|
||||
}) => Promise<TelegramCachedMessageNode | null>;
|
||||
};
|
||||
|
||||
type MessageWithExternalReply = Message & { external_reply?: Message };
|
||||
@@ -712,6 +719,40 @@ export function createTelegramMessageCache(params?: {
|
||||
targetIndex + Math.max(0, after) + 1,
|
||||
);
|
||||
},
|
||||
latestMatchingAtOrBefore: async ({ accountId, chatId, messageId, threadId, matches }) => {
|
||||
if (!messageId) {
|
||||
return null;
|
||||
}
|
||||
const targetId = parseSafeMessageId(messageId);
|
||||
if (targetId === undefined) {
|
||||
return null;
|
||||
}
|
||||
await hydrateMessageCacheBucket(bucket, maxMessages, scopeKey);
|
||||
const prefix = telegramMessageCacheKeyPrefix({ scopeKey, accountId, chatId });
|
||||
const normalizedThreadId = normalizeTelegramCacheThreadId(threadId);
|
||||
if (threadId != null && normalizedThreadId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const normalizedThread =
|
||||
normalizedThreadId !== undefined ? String(normalizedThreadId) : undefined;
|
||||
let latest: TelegramCachedMessageNode | null = null;
|
||||
for (const [key, entry] of messages) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedThread !== undefined && entry.threadId !== normalizedThread) {
|
||||
continue;
|
||||
}
|
||||
const entryId = parseSafeMessageId(entry.messageId);
|
||||
if (entryId === undefined || entryId > targetId || !matches(entry)) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || compareCachedMessageNodes(entry, latest) > 0) {
|
||||
latest = entry;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -789,25 +830,15 @@ async function resolveSessionBoundaryNode(params: {
|
||||
if (!params.messageId) {
|
||||
return undefined;
|
||||
}
|
||||
const { messageId } = params;
|
||||
const candidates = (
|
||||
await params.cache.recentBefore({
|
||||
return (
|
||||
(await params.cache.latestMatchingAtOrBefore({
|
||||
accountId: params.accountId,
|
||||
chatId: params.chatId,
|
||||
messageId,
|
||||
messageId: params.messageId,
|
||||
...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
).filter(isSessionBoundaryCommandNode);
|
||||
const current = await params.cache.get({
|
||||
accountId: params.accountId,
|
||||
chatId: params.chatId,
|
||||
messageId,
|
||||
});
|
||||
if (current && isSessionBoundaryCommandNode(current)) {
|
||||
candidates.push(current);
|
||||
}
|
||||
return candidates.toSorted(compareCachedMessageNodes).at(-1);
|
||||
matches: isSessionBoundaryCommandNode,
|
||||
})) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildTelegramReplyChain(params: {
|
||||
|
||||
+2
-2
@@ -1482,8 +1482,8 @@
|
||||
"crabbox:stop": "node scripts/crabbox-wrapper.mjs stop",
|
||||
"crabbox:warmup": "node scripts/crabbox-wrapper.mjs warmup",
|
||||
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-unused",
|
||||
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx knip@6.8.0 --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
|
||||
"deadcode:knip": "pnpm dlx knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",
|
||||
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
|
||||
"deadcode:knip": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",
|
||||
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",
|
||||
"deadcode:report:ci:knip": "mkdir -p .artifacts/deadcode && pnpm deadcode:knip > .artifacts/deadcode/knip.txt 2>&1 || true",
|
||||
"deadcode:report:ci:ts-prune": "mkdir -p .artifacts/deadcode && pnpm deadcode:ts-prune > .artifacts/deadcode/ts-prune.txt 2>&1 || true",
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
formatValidationErrors,
|
||||
validateChatAbortParams,
|
||||
validateChatHistoryParams,
|
||||
validateChatMetadataParams,
|
||||
validateChatSendParams,
|
||||
validateChatEvent,
|
||||
validateCommandsListParams,
|
||||
@@ -104,6 +105,13 @@ describe("lazy protocol validators", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts selected-agent scope on chat metadata params", () => {
|
||||
expect(validateChatMetadataParams({})).toBe(true);
|
||||
expect(validateChatMetadataParams({ agentId: "work" })).toBe(true);
|
||||
expect(validateChatMetadataParams({ agentId: "" })).toBe(false);
|
||||
expect(validateChatMetadataParams({ agentId: "work", view: "configured" })).toBe(false);
|
||||
});
|
||||
|
||||
it("can still compile every exported protocol validator", () => {
|
||||
const failures: string[] = [];
|
||||
const validators: Array<[string, ProtocolValidator]> = [];
|
||||
|
||||
@@ -126,6 +126,8 @@ import {
|
||||
type ChatEvent,
|
||||
ChatEventSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
type ChatMetadataParams,
|
||||
ChatMetadataParamsSchema,
|
||||
ChatMessageGetResultSchema,
|
||||
ChatMessageGetParamsSchema,
|
||||
type ChatInjectParams,
|
||||
@@ -846,6 +848,7 @@ export const validateExecApprovalsNodeSetParams = lazyCompile<ExecApprovalsNodeS
|
||||
);
|
||||
export const validateLogsTailParams = lazyCompile<LogsTailParams>(LogsTailParamsSchema);
|
||||
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
|
||||
export const validateChatMetadataParams = lazyCompile<ChatMetadataParams>(ChatMetadataParamsSchema);
|
||||
export const validateChatMessageGetParams = lazyCompile(ChatMessageGetParamsSchema);
|
||||
export const validateChatSendParams = lazyCompile(ChatSendParamsSchema);
|
||||
export const validateChatAbortParams = lazyCompile<ChatAbortParams>(ChatAbortParamsSchema);
|
||||
@@ -1115,6 +1118,7 @@ export {
|
||||
ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatMetadataParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
ChatInjectParamsSchema,
|
||||
UpdateRunParamsSchema,
|
||||
@@ -1223,6 +1227,7 @@ export type {
|
||||
ArtifactsDownloadResult,
|
||||
AgentsListParams,
|
||||
AgentsListResult,
|
||||
ChatMetadataParams,
|
||||
CommandsListParams,
|
||||
CommandsListResult,
|
||||
CommandEntry,
|
||||
|
||||
@@ -34,6 +34,13 @@ export const ChatHistoryParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChatMetadataParamsSchema = Type.Object(
|
||||
{
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChatMessageGetParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: NonEmptyString,
|
||||
|
||||
@@ -191,6 +191,7 @@ import {
|
||||
ChatEventSchema,
|
||||
ChatFinalEventSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatMetadataParamsSchema,
|
||||
ChatMessageGetParamsSchema,
|
||||
ChatMessageGetResultSchema,
|
||||
ChatInjectParamsSchema,
|
||||
@@ -534,6 +535,7 @@ export const ProtocolSchemas = {
|
||||
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
ChatMetadataParams: ChatMetadataParamsSchema,
|
||||
ChatMessageGetParams: ChatMessageGetParamsSchema,
|
||||
ChatMessageGetResult: ChatMessageGetResultSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
|
||||
@@ -160,6 +160,7 @@ export type AgentsListResult = SchemaType<"AgentsListResult">;
|
||||
export type ModelChoice = SchemaType<"ModelChoice">;
|
||||
export type ModelsListParams = SchemaType<"ModelsListParams">;
|
||||
export type ModelsListResult = SchemaType<"ModelsListResult">;
|
||||
export type ChatMetadataParams = SchemaType<"ChatMetadataParams">;
|
||||
export type CommandEntry = SchemaType<"CommandEntry">;
|
||||
export type CommandsListParams = SchemaType<"CommandsListParams">;
|
||||
export type CommandsListResult = SchemaType<"CommandsListResult">;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
#!/usr/bin/env node
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { spawn } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import {
|
||||
KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST,
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
|
||||
const KNIP_VERSION = "6.8.0";
|
||||
export const KNIP_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
export const KNIP_KILL_GRACE_MS = 5_000;
|
||||
export const KNIP_HEARTBEAT_MS = 60_000;
|
||||
export const KNIP_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
||||
const KNIP_ARGS = [
|
||||
"--config",
|
||||
@@ -114,28 +116,163 @@ function spawnErrorCode(error) {
|
||||
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
|
||||
}
|
||||
|
||||
export function runKnipUnusedFiles(params = {}) {
|
||||
const run = params.spawnSyncCommand ?? spawnSync;
|
||||
const result = run(
|
||||
"pnpm",
|
||||
["--config.minimum-release-age=0", "dlx", `knip@${KNIP_VERSION}`, ...KNIP_ARGS],
|
||||
{
|
||||
encoding: "utf8",
|
||||
killSignal: "SIGTERM",
|
||||
maxBuffer: params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
timeout: params.timeoutMs ?? KNIP_TIMEOUT_MS,
|
||||
},
|
||||
);
|
||||
return {
|
||||
status: result.status,
|
||||
signal: result.signal,
|
||||
errorCode: spawnErrorCode(result.error),
|
||||
errorMessage: result.error?.message,
|
||||
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
|
||||
};
|
||||
function signalProcessTree(child, signal) {
|
||||
if (!child.pid) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
if (process.platform === "win32") {
|
||||
process.kill(child.pid, signal);
|
||||
} else {
|
||||
process.kill(-child.pid, signal);
|
||||
}
|
||||
} catch {
|
||||
// The child may have exited between the timeout and signal delivery.
|
||||
}
|
||||
}
|
||||
|
||||
export async function runKnipUnusedFiles(params = {}) {
|
||||
const run = params.spawnCommand ?? spawn;
|
||||
const timeoutMs = params.timeoutMs ?? KNIP_TIMEOUT_MS;
|
||||
const heartbeatMs = params.heartbeatMs ?? KNIP_HEARTBEAT_MS;
|
||||
const maxBufferBytes = params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES;
|
||||
const killGraceMs = params.killGraceMs ?? KNIP_KILL_GRACE_MS;
|
||||
const writeStatus = params.writeStatus ?? ((message) => process.stderr.write(`${message}\n`));
|
||||
const args = [
|
||||
"--config.minimum-release-age=0",
|
||||
"dlx",
|
||||
"--package",
|
||||
`knip@${KNIP_VERSION}`,
|
||||
"knip",
|
||||
...KNIP_ARGS,
|
||||
];
|
||||
|
||||
return await new Promise((resolve) => {
|
||||
const startedAt = Date.now();
|
||||
let settled = false;
|
||||
let timedOut = false;
|
||||
let bufferExceeded = false;
|
||||
let outputBytes = 0;
|
||||
const output = [];
|
||||
let killTimer;
|
||||
let exitStatus = null;
|
||||
let exitSignal = null;
|
||||
|
||||
const child = run("pnpm", args, {
|
||||
detached: process.platform !== "win32",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
const heartbeatTimer = setInterval(() => {
|
||||
writeStatus(
|
||||
`[deadcode] Knip unused-file scan still running after ${Math.round(
|
||||
(Date.now() - startedAt) / 1000,
|
||||
)}s.`,
|
||||
);
|
||||
}, heartbeatMs);
|
||||
|
||||
const timeoutTimer = setTimeout(() => {
|
||||
timedOut = true;
|
||||
clearInterval(heartbeatTimer);
|
||||
writeStatus(
|
||||
`[deadcode] Knip unused-file scan timed out after ${Math.round(timeoutMs / 1000)}s; terminating.`,
|
||||
);
|
||||
signalProcessTree(child, "SIGTERM");
|
||||
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
|
||||
}, timeoutMs);
|
||||
|
||||
const finish = (result) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
clearTimeout(timeoutTimer);
|
||||
clearInterval(heartbeatTimer);
|
||||
clearTimeout(killTimer);
|
||||
resolve({
|
||||
...result,
|
||||
output: output.join(""),
|
||||
});
|
||||
};
|
||||
|
||||
const appendOutput = (chunk) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
if (bufferExceeded) {
|
||||
return;
|
||||
}
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
const remainingBytes = maxBufferBytes - outputBytes;
|
||||
if (buffer.length <= remainingBytes) {
|
||||
output.push(buffer.toString("utf8"));
|
||||
outputBytes += buffer.length;
|
||||
return;
|
||||
}
|
||||
if (remainingBytes > 0) {
|
||||
output.push(buffer.subarray(0, remainingBytes).toString("utf8"));
|
||||
outputBytes = maxBufferBytes;
|
||||
}
|
||||
if (!bufferExceeded) {
|
||||
bufferExceeded = true;
|
||||
writeStatus(
|
||||
`[deadcode] Knip unused-file scan exceeded ${maxBufferBytes} output bytes; terminating.`,
|
||||
);
|
||||
child.stdout?.off?.("data", appendOutput);
|
||||
child.stderr?.off?.("data", appendOutput);
|
||||
child.stdout?.destroy?.();
|
||||
child.stderr?.destroy?.();
|
||||
clearInterval(heartbeatTimer);
|
||||
signalProcessTree(child, "SIGTERM");
|
||||
killTimer = setTimeout(() => signalProcessTree(child, "SIGKILL"), killGraceMs);
|
||||
}
|
||||
};
|
||||
|
||||
child.stdout?.on("data", appendOutput);
|
||||
child.stderr?.on("data", appendOutput);
|
||||
child.on("error", (error) =>
|
||||
finish({
|
||||
errorCode: spawnErrorCode(error),
|
||||
errorMessage: error.message,
|
||||
signal: null,
|
||||
status: null,
|
||||
}),
|
||||
);
|
||||
child.on("exit", (status, signal) => {
|
||||
exitStatus = status;
|
||||
exitSignal = signal;
|
||||
});
|
||||
child.on("close", (status, signal) => {
|
||||
exitStatus = exitStatus ?? status;
|
||||
exitSignal = exitSignal ?? signal;
|
||||
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
|
||||
if (timedOut) {
|
||||
finish({
|
||||
errorCode: "ETIMEDOUT",
|
||||
errorMessage: `Knip unused-file scan timed out after ${elapsedSeconds}s`,
|
||||
signal: exitSignal,
|
||||
status: exitStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (bufferExceeded) {
|
||||
finish({
|
||||
errorCode: "ENOBUFS",
|
||||
errorMessage: `Knip unused-file scan exceeded ${maxBufferBytes} output bytes`,
|
||||
signal: exitSignal,
|
||||
status: exitStatus,
|
||||
});
|
||||
return;
|
||||
}
|
||||
finish({
|
||||
errorCode: undefined,
|
||||
errorMessage: undefined,
|
||||
signal: exitSignal,
|
||||
status: exitStatus,
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
export function checkUnusedFiles(
|
||||
output,
|
||||
allowlistFiles = KNIP_UNUSED_FILE_ALLOWLIST,
|
||||
@@ -154,8 +291,8 @@ export function checkUnusedFiles(
|
||||
};
|
||||
}
|
||||
|
||||
function main() {
|
||||
const result = runKnipUnusedFiles();
|
||||
async function main() {
|
||||
const result = await runKnipUnusedFiles();
|
||||
if (result.errorCode || result.status === null) {
|
||||
console.error(
|
||||
`deadcode unused-file scan failed: ${result.errorCode ?? result.signal ?? "unknown"}${
|
||||
@@ -183,5 +320,5 @@ function main() {
|
||||
}
|
||||
|
||||
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
||||
main();
|
||||
await main();
|
||||
}
|
||||
|
||||
@@ -63,6 +63,7 @@ try {
|
||||
}),
|
||||
);
|
||||
if (extract.status !== 0) {
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
fail(`tar -xf failed for ${tarball}: ${extract.stderr || extract.status}`);
|
||||
}
|
||||
} catch (error) {
|
||||
|
||||
+24
-12
@@ -1659,7 +1659,7 @@ function remoteAwsMacosJsBootstrap({ packageManager = false } = {}) {
|
||||
'mkdir -p "$tool_root" || { status=$?; return "$status"; };',
|
||||
'install_lock="$tool_root/.node-${node_version}-${node_arch}.lock";',
|
||||
"lock_acquired=0;",
|
||||
'lock_deadline=$((SECONDS + 300));',
|
||||
"lock_deadline=$((SECONDS + 300));",
|
||||
"while true; do",
|
||||
'if mkdir "$install_lock" 2>/dev/null; then lock_acquired=1; printf "%s\\n" "$$" >"$install_lock/pid" || { status=$?; rm -rf "$install_lock"; return "$status"; }; break; fi;',
|
||||
'if [ -x "$node_dir/bin/node" ] && [ -f "$ready_marker" ]; then break; fi;',
|
||||
@@ -1668,11 +1668,11 @@ function remoteAwsMacosJsBootstrap({ packageManager = false } = {}) {
|
||||
'if [ -n "$lock_pid" ] && kill -0 "$lock_pid" 2>/dev/null; then echo "timed out waiting for active macOS Node toolchain install lock: $install_lock pid=$lock_pid" >&2; return 1; fi;',
|
||||
'echo "reclaiming stale macOS Node toolchain install lock: $install_lock" >&2;',
|
||||
'rm -rf "$install_lock" || return 1;',
|
||||
'lock_deadline=$((SECONDS + 300));',
|
||||
"lock_deadline=$((SECONDS + 300));",
|
||||
"fi;",
|
||||
"sleep 1;",
|
||||
"done;",
|
||||
"release_install_lock() { if [ \"$lock_acquired\" = \"1\" ]; then rm -rf \"$install_lock\" 2>/dev/null || true; fi; };",
|
||||
'release_install_lock() { if [ "$lock_acquired" = "1" ]; then rm -rf "$install_lock" 2>/dev/null || true; fi; };',
|
||||
'if [ ! -x "$node_dir/bin/node" ] || [ ! -f "$ready_marker" ]; then',
|
||||
'tmp_dir="$(mktemp -d)" || { release_install_lock; return 1; };',
|
||||
'pkg="node-v${node_version}-darwin-${node_arch}.tar.gz";',
|
||||
@@ -1938,15 +1938,20 @@ function fullCheckoutSyncRoot() {
|
||||
return root;
|
||||
}
|
||||
|
||||
function parsePositiveIntegerEnv(name, fallback) {
|
||||
function parseNonNegativeIntegerEnv(name, fallback, unit) {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
if (!/^\d+$/u.test(raw)) {
|
||||
throw new Error(`${name} must be a non-negative integer byte count, got ${JSON.stringify(raw)}`);
|
||||
throw new Error(`${name} must be a non-negative integer ${unit}, got ${JSON.stringify(raw)}`);
|
||||
}
|
||||
const parsed = Number(raw);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(
|
||||
`${name} must be a safe non-negative integer ${unit}, got ${JSON.stringify(raw)}`,
|
||||
);
|
||||
}
|
||||
const parsed = Number.parseInt(raw, 10);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
@@ -1965,9 +1970,10 @@ function formatByteCount(bytes) {
|
||||
}
|
||||
|
||||
function assertFullCheckoutSyncDisk(root) {
|
||||
const requiredBytes = parsePositiveIntegerEnv(
|
||||
const requiredBytes = parseNonNegativeIntegerEnv(
|
||||
"OPENCLAW_CRABBOX_SYNC_MIN_FREE_BYTES",
|
||||
1024 * 1024 * 1024,
|
||||
"byte count",
|
||||
);
|
||||
if (requiredBytes === 0) {
|
||||
return;
|
||||
@@ -2066,11 +2072,12 @@ function startFullCheckoutKeepalive(checkout) {
|
||||
};
|
||||
|
||||
refresh();
|
||||
const intervalMs = Number.parseInt(
|
||||
process.env.OPENCLAW_CRABBOX_SYNC_KEEPALIVE_MS ?? "5000",
|
||||
10,
|
||||
const intervalMs = parseNonNegativeIntegerEnv(
|
||||
"OPENCLAW_CRABBOX_SYNC_KEEPALIVE_MS",
|
||||
5000,
|
||||
"millisecond interval",
|
||||
);
|
||||
if (!Number.isFinite(intervalMs) || intervalMs <= 0) {
|
||||
if (intervalMs <= 0) {
|
||||
return () => {};
|
||||
}
|
||||
|
||||
@@ -2361,7 +2368,12 @@ const childArgs =
|
||||
remoteChangedGateBase,
|
||||
);
|
||||
if (fullCheckout) {
|
||||
stopFullCheckoutKeepalive = startFullCheckoutKeepalive(fullCheckout);
|
||||
try {
|
||||
stopFullCheckoutKeepalive = startFullCheckoutKeepalive(fullCheckout);
|
||||
} catch (error) {
|
||||
cleanupOnce();
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
const childInvocation = spawnInvocation(binary, childArgs, childEnv, process.platform);
|
||||
const child = spawn(childInvocation.command, childInvocation.args, {
|
||||
|
||||
@@ -2,8 +2,8 @@
|
||||
// generated/build inputs, manifest-discovered plugin surfaces, live-test
|
||||
// helpers, or package bridge files that static production scanning cannot see.
|
||||
export const KNIP_UNUSED_FILE_ALLOWLIST = [
|
||||
// Per-agent SQLite scaffold is intentionally landed before runtime migration
|
||||
// callers so the schema and scoped cache API can be reviewed together.
|
||||
// Per-agent SQLite scaffold is intentionally ahead of mainline runtime callers.
|
||||
// The pending SQLite session/runtime branch wires these files into production.
|
||||
"src/agents/cache/agent-cache-store.sqlite.ts",
|
||||
"src/agents/cache/agent-cache-store.ts",
|
||||
"src/state/openclaw-agent-db.paths.ts",
|
||||
|
||||
@@ -6,13 +6,11 @@ import path from "node:path";
|
||||
import { setTimeout as delay } from "node:timers/promises";
|
||||
import { pathToFileURL } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { readPositiveIntEnv } from "./lib/env-limits.mjs";
|
||||
import type { GatewayRpcClient } from "./mcp-channels-harness.ts";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const PROBE_PID_WAIT_MS = readPositiveInt(
|
||||
process.env.OPENCLAW_CRON_MCP_CLEANUP_PID_WAIT_MS,
|
||||
120_000,
|
||||
);
|
||||
const PROBE_PID_WAIT_MS = readCronMcpCleanupProbePidWaitMs();
|
||||
type McpChannelsHarness = typeof import("./mcp-channels-harness.ts");
|
||||
let mcpChannelsHarness: McpChannelsHarness | undefined;
|
||||
|
||||
@@ -25,13 +23,8 @@ async function loadMcpChannelsHarness(): Promise<McpChannelsHarness> {
|
||||
return mcpChannelsHarness;
|
||||
}
|
||||
|
||||
function readPositiveInt(raw: string | undefined, fallback: number): number {
|
||||
const text = (raw ?? "").trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number(text);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
export function readCronMcpCleanupProbePidWaitMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
return readPositiveIntEnv("OPENCLAW_CRON_MCP_CLEANUP_PID_WAIT_MS", 120_000, env);
|
||||
}
|
||||
|
||||
async function readProbePid(pidPath: string): Promise<number | undefined> {
|
||||
|
||||
@@ -155,6 +155,11 @@ export async function cleanupKitchenSinkEnv(root, options = {}) {
|
||||
const message = lastError instanceof Error ? lastError.message : String(lastError);
|
||||
console.error(`Kitchen Sink RPC temp root cleanup failed; preserved ${root}: ${message}`);
|
||||
}
|
||||
if (options.throwOnFailure) {
|
||||
throw new Error(`failed to remove Kitchen Sink RPC temp root: ${root}`, {
|
||||
cause: lastError,
|
||||
});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
@@ -1686,7 +1691,7 @@ export async function main() {
|
||||
}
|
||||
await stopGateway(child);
|
||||
if (!failed && !keepTmp) {
|
||||
await cleanupKitchenSinkEnv(root);
|
||||
await cleanupKitchenSinkEnv(root, { throwOnFailure: true });
|
||||
} else if (failed || keepTmp) {
|
||||
console.error(`Kitchen Sink RPC temp root preserved: ${root}`);
|
||||
}
|
||||
|
||||
@@ -88,20 +88,44 @@ function assertMockModelConfig() {
|
||||
function assertChannelConfig() {
|
||||
const channel = process.argv[3];
|
||||
const expectedTokens = process.argv.slice(4);
|
||||
if (expectedTokens.length === 0) {
|
||||
throw new Error("assert-channel-config requires at least one expected token");
|
||||
}
|
||||
const configPath = path.join(process.env.HOME, ".openclaw", "openclaw.json");
|
||||
const cfg = readJson(configPath);
|
||||
const entry = cfg.channels?.[channel];
|
||||
if (!entry || entry.enabled === false) {
|
||||
throw new Error(`${channel} was not enabled`);
|
||||
}
|
||||
const serializedEntry = JSON.stringify(entry);
|
||||
for (const token of expectedTokens) {
|
||||
if (!serializedEntry.includes(token)) {
|
||||
throw new Error(`${channel} token was not persisted`);
|
||||
const assertTokenField = (field, expected) => {
|
||||
if (entry[field] !== expected) {
|
||||
throw new Error(
|
||||
`${channel} config did not persist ${field}; expected ${expected}, got ${JSON.stringify(entry[field])}`,
|
||||
);
|
||||
}
|
||||
};
|
||||
switch (channel) {
|
||||
case "telegram": {
|
||||
if (expectedTokens.length !== 1) {
|
||||
throw new Error("telegram channel config assertion requires one bot token");
|
||||
}
|
||||
assertTokenField("botToken", expectedTokens[0]);
|
||||
return;
|
||||
}
|
||||
case "discord": {
|
||||
if (expectedTokens.length !== 1) {
|
||||
throw new Error("discord channel config assertion requires one bot token");
|
||||
}
|
||||
assertTokenField("token", expectedTokens[0]);
|
||||
return;
|
||||
}
|
||||
case "slack": {
|
||||
if (expectedTokens.length !== 2) {
|
||||
throw new Error("slack channel config assertion requires bot and app tokens");
|
||||
}
|
||||
assertTokenField("botToken", expectedTokens[0]);
|
||||
assertTokenField("appToken", expectedTokens[1]);
|
||||
return;
|
||||
}
|
||||
default:
|
||||
throw new Error(`unsupported channel config assertion: ${channel}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -64,13 +64,7 @@ MOCK_PORT="$MOCK_PORT" \
|
||||
node scripts/e2e/lib/openai-web-search-minimal/mock-server.mjs >"$MOCK_LOG" 2>&1 &
|
||||
mock_pid="$!"
|
||||
|
||||
for _ in $(seq 1 80); do
|
||||
if node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null 2>&1; then
|
||||
break
|
||||
fi
|
||||
sleep 0.1
|
||||
done
|
||||
node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
|
||||
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360
|
||||
|
||||
@@ -19,27 +19,19 @@ plugin_id="lifecycle-claw"
|
||||
package_name="@openclaw/lifecycle-claw"
|
||||
probe="scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs"
|
||||
measure="scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs"
|
||||
resource_dir="/tmp/openclaw-plugin-lifecycle-matrix"
|
||||
resource_dir="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-matrix.XXXXXX")"
|
||||
pack_root=""
|
||||
registry_root=""
|
||||
tarball_v1="$resource_dir/lifecycle-claw-1.0.0.tgz"
|
||||
tarball_v2="$resource_dir/lifecycle-claw-2.0.0.tgz"
|
||||
inspect_v1="$resource_dir/plugin-lifecycle-inspect-v1.json"
|
||||
|
||||
cleanup() {
|
||||
openclaw_plugins_cleanup_fixture_servers
|
||||
rm -rf "$resource_dir"
|
||||
if [ -n "$pack_root" ]; then
|
||||
rm -rf "$pack_root"
|
||||
fi
|
||||
if [ -n "$registry_root" ]; then
|
||||
rm -rf "$registry_root"
|
||||
fi
|
||||
rm -f \
|
||||
/tmp/lifecycle-claw-1.0.0.tgz \
|
||||
/tmp/lifecycle-claw-2.0.0.tgz \
|
||||
/tmp/plugin-lifecycle-inspect-v1.json
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
mkdir -p "$resource_dir"
|
||||
summary_tsv="$resource_dir/resource-summary.tsv"
|
||||
printf "phase\tmax_rss_kb\tcpu_seconds\twall_ms\tcpu_core_ratio\tsignal\n" >"$summary_tsv"
|
||||
|
||||
@@ -51,19 +43,19 @@ run_measured() {
|
||||
node "$measure" "$summary_tsv" "$phase" -- "$@"
|
||||
}
|
||||
|
||||
pack_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-pack.XXXXXX")"
|
||||
registry_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-registry.XXXXXX")"
|
||||
pack_fixture_plugin "$pack_root/v1" /tmp/lifecycle-claw-1.0.0.tgz "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
|
||||
pack_fixture_plugin "$pack_root/v2" /tmp/lifecycle-claw-2.0.0.tgz "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
|
||||
start_npm_fixture_registry "$package_name" 1.0.0 /tmp/lifecycle-claw-1.0.0.tgz "$registry_root" "$package_name" 2.0.0 /tmp/lifecycle-claw-2.0.0.tgz
|
||||
pack_root="$(mktemp -d "$resource_dir/pack.XXXXXX")"
|
||||
registry_root="$(mktemp -d "$resource_dir/registry.XXXXXX")"
|
||||
pack_fixture_plugin "$pack_root/v1" "$tarball_v1" "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
|
||||
pack_fixture_plugin "$pack_root/v2" "$tarball_v2" "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
|
||||
start_npm_fixture_registry "$package_name" 1.0.0 "$tarball_v1" "$registry_root" "$package_name" 2.0.0 "$tarball_v2"
|
||||
trap cleanup EXIT
|
||||
|
||||
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
|
||||
node "$probe" assert-version "$plugin_id" 1.0.0
|
||||
node "$probe" assert-npm-project-root "$plugin_id" "$package_name"
|
||||
|
||||
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >/tmp/plugin-lifecycle-inspect-v1.json' bash "$entry" "$plugin_id"
|
||||
node "$probe" assert-inspect-loaded "$plugin_id" /tmp/plugin-lifecycle-inspect-v1.json
|
||||
run_measured inspect-v1 bash -c 'node "$1" plugins inspect "$2" --runtime --json >"$3"' bash "$entry" "$plugin_id" "$inspect_v1"
|
||||
node "$probe" assert-inspect-loaded "$plugin_id" "$inspect_v1"
|
||||
|
||||
run_measured disable node "$entry" plugins disable "$plugin_id"
|
||||
node "$probe" assert-enabled "$plugin_id" false
|
||||
|
||||
@@ -20,6 +20,12 @@ PORT="18789"
|
||||
MOCK_PORT="44190"
|
||||
SUCCESS_MARKER="OPENCLAW_E2E_OK_TYPED_ONBOARDING"
|
||||
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-typed-onboarding.XXXXXX")"
|
||||
LOG_DIR="$scenario_tmp/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
INSTALL_LOG="$LOG_DIR/install.log"
|
||||
ONBOARD_LOG="$LOG_DIR/onboard.log"
|
||||
OPENAI_LOG="$LOG_DIR/openai.log"
|
||||
AGENT_LOG="$LOG_DIR/agent.log"
|
||||
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
|
||||
export SUCCESS_MARKER MOCK_REQUEST_LOG
|
||||
|
||||
@@ -41,11 +47,11 @@ dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "release typed onboarding failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
/tmp/openclaw-release-typed-onboarding-install.log \
|
||||
/tmp/openclaw-release-typed-onboarding.log \
|
||||
/tmp/openclaw-release-typed-onboarding-openai.log \
|
||||
"$INSTALL_LOG" \
|
||||
"$ONBOARD_LOG" \
|
||||
"$OPENAI_LOG" \
|
||||
"$MOCK_REQUEST_LOG" \
|
||||
/tmp/openclaw-release-typed-onboarding-agent.log \
|
||||
"$AGENT_LOG" \
|
||||
"$OPENCLAW_CONFIG_PATH" \
|
||||
"$HOME/.openclaw/agents/main/agent/auth-profiles.json"
|
||||
}
|
||||
@@ -64,36 +70,36 @@ wait_for_log() {
|
||||
local start_s
|
||||
start_s="$(date +%s)"
|
||||
while true; do
|
||||
if [ -f /tmp/openclaw-release-typed-onboarding.log ]; then
|
||||
if grep -a -F -q "$needle" /tmp/openclaw-release-typed-onboarding.log; then
|
||||
if [ -f "$ONBOARD_LOG" ]; then
|
||||
if grep -a -F -q "$needle" "$ONBOARD_LOG"; then
|
||||
return 0
|
||||
fi
|
||||
if node scripts/e2e/lib/onboard/log-contains.mjs /tmp/openclaw-release-typed-onboarding.log "$needle"; then
|
||||
if node scripts/e2e/lib/onboard/log-contains.mjs "$ONBOARD_LOG" "$needle"; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
if [ $(($(date +%s) - start_s)) -ge "$timeout_s" ]; then
|
||||
echo "Timeout waiting for log: $needle" >&2
|
||||
tail -n 120 /tmp/openclaw-release-typed-onboarding.log 2>/dev/null || true
|
||||
tail -n 120 "$ONBOARD_LOG" 2>/dev/null || true
|
||||
return 1
|
||||
fi
|
||||
sleep 0.2
|
||||
done
|
||||
}
|
||||
|
||||
openclaw_e2e_install_package /tmp/openclaw-release-typed-onboarding-install.log
|
||||
openclaw_e2e_install_package "$INSTALL_LOG"
|
||||
command -v openclaw >/dev/null
|
||||
package_root="$(openclaw_e2e_package_root)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" /tmp/openclaw-release-typed-onboarding-openai.log)"
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
input_fifo_dir="$(mktemp -d "/tmp/openclaw-release-typed-onboarding.XXXXXX")"
|
||||
input_fifo_dir="$(mktemp -d "$scenario_tmp/input.XXXXXX")"
|
||||
input_fifo="$input_fifo_dir/stdin.fifo"
|
||||
mkfifo "$input_fifo"
|
||||
openclaw_e2e_run_script_with_pty "node \"$entry\" onboard --flow quickstart --mode local --auth-choice skip --gateway-port \"$PORT\" --gateway-bind loopback --skip-daemon --skip-ui --skip-channels --skip-skills --skip-health" /tmp/openclaw-release-typed-onboarding.log <"$input_fifo" >/dev/null 2>&1 &
|
||||
openclaw_e2e_run_script_with_pty "node \"$entry\" onboard --flow quickstart --mode local --auth-choice skip --gateway-port \"$PORT\" --gateway-bind loopback --skip-daemon --skip-ui --skip-channels --skip-skills --skip-health" "$ONBOARD_LOG" <"$input_fifo" >/dev/null 2>&1 &
|
||||
wizard_pid="$!"
|
||||
exec 3>"$input_fifo"
|
||||
|
||||
@@ -124,7 +130,7 @@ openclaw onboard \
|
||||
--skip-ui \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-health >>/tmp/openclaw-release-typed-onboarding.log 2>&1
|
||||
--skip-health >>"$ONBOARD_LOG" 2>&1
|
||||
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-openai-env-ref "$OPENAI_API_KEY"
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
|
||||
@@ -134,7 +140,7 @@ openclaw agent --local \
|
||||
--session-id release-typed-onboarding-agent \
|
||||
--message "Return marker $SUCCESS_MARKER" \
|
||||
--thinking off \
|
||||
--json >/tmp/openclaw-release-typed-onboarding-agent.log 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" /tmp/openclaw-release-typed-onboarding-agent.log "$MOCK_REQUEST_LOG"
|
||||
--json >"$AGENT_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
|
||||
|
||||
echo "Release typed onboarding scenario passed."
|
||||
|
||||
@@ -22,6 +22,22 @@ MOCK_PORT="44210"
|
||||
CLICKCLACK_PORT="44211"
|
||||
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_UPGRADE"
|
||||
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-upgrade-user-journey.XXXXXX")"
|
||||
LOG_DIR="$scenario_tmp/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
BASELINE_INSTALL_LOG="$LOG_DIR/baseline-install.log"
|
||||
CANDIDATE_INSTALL_LOG="$LOG_DIR/candidate-install.log"
|
||||
ONBOARD_LOG="$LOG_DIR/onboard.log"
|
||||
OPENAI_LOG="$LOG_DIR/openai.log"
|
||||
PLUGIN_INSTALL_LOG="$LOG_DIR/plugin-install.log"
|
||||
PLUGIN_CLI_BEFORE_LOG="$LOG_DIR/plugin-cli-before.log"
|
||||
PLUGIN_CLI_AFTER_LOG="$LOG_DIR/plugin-cli-after.log"
|
||||
AGENT_LOG="$LOG_DIR/agent.log"
|
||||
STATUS_JSON="$LOG_DIR/status.json"
|
||||
STATUS_ERR="$LOG_DIR/status.err"
|
||||
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
|
||||
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
|
||||
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
|
||||
GATEWAY_LOG="$LOG_DIR/gateway.log"
|
||||
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
|
||||
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
|
||||
BASELINE_SPEC="${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-openclaw@latest}"
|
||||
@@ -47,19 +63,19 @@ dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "release upgrade user journey failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
/tmp/openclaw-release-upgrade-baseline-install.log \
|
||||
/tmp/openclaw-release-upgrade-candidate-install.log \
|
||||
/tmp/openclaw-release-upgrade-onboard.log \
|
||||
/tmp/openclaw-release-upgrade-openai.log \
|
||||
"$BASELINE_INSTALL_LOG" \
|
||||
"$CANDIDATE_INSTALL_LOG" \
|
||||
"$ONBOARD_LOG" \
|
||||
"$OPENAI_LOG" \
|
||||
"$MOCK_REQUEST_LOG" \
|
||||
/tmp/openclaw-release-upgrade-plugin-install.log \
|
||||
/tmp/openclaw-release-upgrade-plugin-cli-before.log \
|
||||
/tmp/openclaw-release-upgrade-plugin-cli-after.log \
|
||||
/tmp/openclaw-release-upgrade-agent.log \
|
||||
/tmp/openclaw-release-upgrade-status.json \
|
||||
/tmp/openclaw-release-upgrade-clickclack-outbound.json \
|
||||
/tmp/openclaw-release-upgrade-clickclack-server.log \
|
||||
/tmp/openclaw-release-upgrade-gateway.log \
|
||||
"$PLUGIN_INSTALL_LOG" \
|
||||
"$PLUGIN_CLI_BEFORE_LOG" \
|
||||
"$PLUGIN_CLI_AFTER_LOG" \
|
||||
"$AGENT_LOG" \
|
||||
"$STATUS_JSON" \
|
||||
"$CLICKCLACK_OUTBOUND_JSON" \
|
||||
"$CLICKCLACK_SERVER_LOG" \
|
||||
"$GATEWAY_LOG" \
|
||||
"$CLICKCLACK_STATE"
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
@@ -71,8 +87,8 @@ start_gateway() {
|
||||
}
|
||||
|
||||
echo "Installing published baseline $BASELINE_SPEC..."
|
||||
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g "$BASELINE_SPEC" --no-fund --no-audit >/tmp/openclaw-release-upgrade-baseline-install.log 2>&1; then
|
||||
cat /tmp/openclaw-release-upgrade-baseline-install.log >&2 || true
|
||||
if ! openclaw_e2e_maybe_timeout "${OPENCLAW_E2E_NPM_INSTALL_TIMEOUT:-600s}" npm install -g "$BASELINE_SPEC" --no-fund --no-audit >"$BASELINE_INSTALL_LOG" 2>&1; then
|
||||
cat "$BASELINE_INSTALL_LOG" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
command -v openclaw >/dev/null
|
||||
@@ -80,13 +96,13 @@ baseline_root="$(openclaw_e2e_package_root)"
|
||||
baseline_entry="$(openclaw_e2e_package_entrypoint "$baseline_root")"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" /tmp/openclaw-release-upgrade-openai.log)"
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
|
||||
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
|
||||
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
|
||||
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >/tmp/openclaw-release-upgrade-clickclack-server.log 2>&1 &
|
||||
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
|
||||
clickclack_pid="$!"
|
||||
for _ in $(seq 1 100); do
|
||||
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
|
||||
@@ -108,10 +124,10 @@ openclaw_e2e_run_command node "$baseline_entry" onboard \
|
||||
--skip-ui \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-health >/tmp/openclaw-release-upgrade-onboard.log 2>&1
|
||||
--skip-health >"$ONBOARD_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
|
||||
|
||||
plugin_dir="$(mktemp -d "/tmp/openclaw-release-upgrade-plugin.XXXXXX")"
|
||||
plugin_dir="$(mktemp -d "$scenario_tmp/plugin.XXXXXX")"
|
||||
node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
|
||||
"$plugin_dir" \
|
||||
release-upgrade-plugin \
|
||||
@@ -120,12 +136,12 @@ node scripts/e2e/lib/release-scenarios/write-cli-plugin.mjs \
|
||||
"Release Upgrade Plugin" \
|
||||
release-upgrade \
|
||||
"release-upgrade-plugin:pong"
|
||||
openclaw plugins install "$plugin_dir" >/tmp/openclaw-release-upgrade-plugin-install.log 2>&1
|
||||
openclaw release-upgrade ping >/tmp/openclaw-release-upgrade-plugin-cli-before.log 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-upgrade-plugin-cli-before.log "release-upgrade-plugin:pong"
|
||||
openclaw plugins install "$plugin_dir" >"$PLUGIN_INSTALL_LOG" 2>&1
|
||||
openclaw release-upgrade ping >"$PLUGIN_CLI_BEFORE_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_BEFORE_LOG" "release-upgrade-plugin:pong"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
|
||||
|
||||
openclaw_e2e_install_package /tmp/openclaw-release-upgrade-candidate-install.log "candidate OpenClaw package"
|
||||
openclaw_e2e_install_package "$CANDIDATE_INSTALL_LOG" "candidate OpenClaw package"
|
||||
package_root="$(openclaw_e2e_package_root)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
@@ -136,22 +152,22 @@ openclaw agent --local \
|
||||
--session-id release-upgrade-user-journey-agent \
|
||||
--message "Return marker $SUCCESS_MARKER" \
|
||||
--thinking off \
|
||||
--json >/tmp/openclaw-release-upgrade-agent.log 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" /tmp/openclaw-release-upgrade-agent.log "$MOCK_REQUEST_LOG"
|
||||
--json >"$AGENT_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
|
||||
|
||||
openclaw release-upgrade ping >/tmp/openclaw-release-upgrade-plugin-cli-after.log 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-upgrade-plugin-cli-after.log "release-upgrade-plugin:pong"
|
||||
openclaw release-upgrade ping >"$PLUGIN_CLI_AFTER_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains "$PLUGIN_CLI_AFTER_LOG" "release-upgrade-plugin:pong"
|
||||
|
||||
openclaw channels status --json >/tmp/openclaw-release-upgrade-status.json 2>/tmp/openclaw-release-upgrade-status.err
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack /tmp/openclaw-release-upgrade-status.json
|
||||
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
|
||||
openclaw message send \
|
||||
--channel clickclack \
|
||||
--target channel:general \
|
||||
--message "release upgrade outbound" \
|
||||
--json >/tmp/openclaw-release-upgrade-clickclack-outbound.json 2>/tmp/openclaw-release-upgrade-clickclack-outbound.err
|
||||
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release upgrade outbound"
|
||||
|
||||
start_gateway /tmp/openclaw-release-upgrade-gateway.log
|
||||
start_gateway "$GATEWAY_LOG"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
|
||||
|
||||
@@ -23,6 +23,30 @@ MOCK_PORT="44180"
|
||||
CLICKCLACK_PORT="44181"
|
||||
SUCCESS_MARKER="OPENCLAW_E2E_OK_RELEASE_USER_JOURNEY"
|
||||
scenario_tmp="$(mktemp -d "${TMPDIR:-/tmp}/openclaw-release-user-journey.XXXXXX")"
|
||||
LOG_DIR="$scenario_tmp/logs"
|
||||
mkdir -p "$LOG_DIR"
|
||||
INSTALL_LOG="$LOG_DIR/install.log"
|
||||
ONBOARD_LOG="$LOG_DIR/onboard.log"
|
||||
OPENAI_LOG="$LOG_DIR/openai.log"
|
||||
AGENT_LOG="$LOG_DIR/agent.log"
|
||||
PLUGIN_A_INSTALL_LOG="$LOG_DIR/plugin-a-install.log"
|
||||
PLUGIN_A_CLI_LOG="$LOG_DIR/plugin-a-cli.log"
|
||||
PLUGIN_A_UNINSTALL_LOG="$LOG_DIR/plugin-a-uninstall.log"
|
||||
PLUGIN_B_INSTALL_LOG="$LOG_DIR/plugin-b-install.log"
|
||||
PLUGIN_B_CLI_LOG="$LOG_DIR/plugin-b-cli.log"
|
||||
PLUGIN_B_AFTER_RESTART_JSON="$LOG_DIR/plugin-b-after-restart.json"
|
||||
CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
|
||||
CLICKCLACK_OUTBOUND_JSON="$LOG_DIR/clickclack-outbound.json"
|
||||
CLICKCLACK_OUTBOUND_ERR="$LOG_DIR/clickclack-outbound.err"
|
||||
GATEWAY_1_LOG="$LOG_DIR/gateway-1.log"
|
||||
GATEWAY_2_LOG="$LOG_DIR/gateway-2.log"
|
||||
STATUS_JSON="$LOG_DIR/status.json"
|
||||
STATUS_ERR="$LOG_DIR/status.err"
|
||||
STATUS_AFTER_RESTART_JSON="$LOG_DIR/status-after-restart.json"
|
||||
STATUS_AFTER_RESTART_ERR="$LOG_DIR/status-after-restart.err"
|
||||
DOCTOR_LOG="$LOG_DIR/doctor.log"
|
||||
PLUGIN_A_INSTALL_PATH_FILE="$scenario_tmp/plugin-a-install-path.txt"
|
||||
PLUGIN_A_SOURCE_PATH_FILE="$scenario_tmp/plugin-a-source-path.txt"
|
||||
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
|
||||
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
|
||||
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
|
||||
@@ -43,24 +67,23 @@ dump_debug_logs() {
|
||||
local status="$1"
|
||||
echo "release user journey failed with exit code $status" >&2
|
||||
openclaw_e2e_dump_logs \
|
||||
/tmp/openclaw-release-user-journey-install.log \
|
||||
/tmp/openclaw-release-user-journey-onboard.log \
|
||||
/tmp/openclaw-release-user-journey-openai.log \
|
||||
"$INSTALL_LOG" \
|
||||
"$ONBOARD_LOG" \
|
||||
"$OPENAI_LOG" \
|
||||
"$MOCK_REQUEST_LOG" \
|
||||
/tmp/openclaw-release-user-journey-agent.log \
|
||||
/tmp/openclaw-release-user-journey-plugin-a-install.log \
|
||||
/tmp/openclaw-release-user-journey-plugin-a-cli.log \
|
||||
/tmp/openclaw-release-user-journey-plugin-a-uninstall.log \
|
||||
/tmp/openclaw-release-user-journey-plugin-b-install.log \
|
||||
/tmp/openclaw-release-user-journey-plugin-b-cli.log \
|
||||
/tmp/openclaw-release-user-journey-clickclack.log \
|
||||
/tmp/openclaw-release-user-journey-clickclack-server.log \
|
||||
/tmp/openclaw-release-user-journey-clickclack-outbound.json \
|
||||
/tmp/openclaw-release-user-journey-clickclack-inbound.json \
|
||||
/tmp/openclaw-release-user-journey-gateway-1.log \
|
||||
/tmp/openclaw-release-user-journey-gateway-2.log \
|
||||
/tmp/openclaw-release-user-journey-status.json \
|
||||
/tmp/openclaw-release-user-journey-doctor.log \
|
||||
"$AGENT_LOG" \
|
||||
"$PLUGIN_A_INSTALL_LOG" \
|
||||
"$PLUGIN_A_CLI_LOG" \
|
||||
"$PLUGIN_A_UNINSTALL_LOG" \
|
||||
"$PLUGIN_B_INSTALL_LOG" \
|
||||
"$PLUGIN_B_CLI_LOG" \
|
||||
"$CLICKCLACK_SERVER_LOG" \
|
||||
"$CLICKCLACK_OUTBOUND_JSON" \
|
||||
"$GATEWAY_1_LOG" \
|
||||
"$GATEWAY_2_LOG" \
|
||||
"$STATUS_JSON" \
|
||||
"$STATUS_AFTER_RESTART_JSON" \
|
||||
"$DOCTOR_LOG" \
|
||||
"$CLICKCLACK_STATE"
|
||||
}
|
||||
trap 'status=$?; dump_debug_logs "$status"; exit "$status"' ERR
|
||||
@@ -114,19 +137,19 @@ fs.writeFileSync(
|
||||
NODE
|
||||
}
|
||||
|
||||
openclaw_e2e_install_package /tmp/openclaw-release-user-journey-install.log
|
||||
openclaw_e2e_install_package "$INSTALL_LOG"
|
||||
command -v openclaw >/dev/null
|
||||
package_root="$(openclaw_e2e_package_root)"
|
||||
entry="$(openclaw_e2e_package_entrypoint "$package_root")"
|
||||
openclaw_e2e_enable_openclaw_cli_timeout
|
||||
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" /tmp/openclaw-release-user-journey-openai.log)"
|
||||
mock_pid="$(openclaw_e2e_start_mock_openai "$MOCK_PORT" "$OPENAI_LOG")"
|
||||
openclaw_e2e_wait_mock_openai "$MOCK_PORT"
|
||||
|
||||
CLICKCLACK_FIXTURE_PORT="$CLICKCLACK_PORT" \
|
||||
CLICKCLACK_FIXTURE_TOKEN="$CLICKCLACK_BOT_TOKEN" \
|
||||
CLICKCLACK_FIXTURE_STATE="$CLICKCLACK_STATE" \
|
||||
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >/tmp/openclaw-release-user-journey-clickclack-server.log 2>&1 &
|
||||
node scripts/e2e/lib/release-user-journey/clickclack-fixture.mjs >"$CLICKCLACK_SERVER_LOG" 2>&1 &
|
||||
clickclack_pid="$!"
|
||||
for _ in $(seq 1 100); do
|
||||
if openclaw_e2e_probe_http_status "http://127.0.0.1:$CLICKCLACK_PORT/health" 200 >/dev/null 2>&1; then
|
||||
@@ -149,7 +172,7 @@ openclaw onboard \
|
||||
--skip-ui \
|
||||
--skip-channels \
|
||||
--skip-skills \
|
||||
--skip-health >/tmp/openclaw-release-user-journey-onboard.log 2>&1
|
||||
--skip-health >"$ONBOARD_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-onboard "$HOME"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-mock-model "$MOCK_PORT"
|
||||
|
||||
@@ -159,26 +182,26 @@ openclaw agent --local \
|
||||
--session-id release-user-journey-agent \
|
||||
--message "Return marker $SUCCESS_MARKER" \
|
||||
--thinking off \
|
||||
--json >/tmp/openclaw-release-user-journey-agent.log 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" /tmp/openclaw-release-user-journey-agent.log "$MOCK_REQUEST_LOG"
|
||||
--json >"$AGENT_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-agent-turn "$SUCCESS_MARKER" "$AGENT_LOG" "$MOCK_REQUEST_LOG"
|
||||
|
||||
echo "Installing first external plugin..."
|
||||
plugin_a_dir="$(mktemp -d "/tmp/openclaw-release-journey-plugin-a.XXXXXX")"
|
||||
plugin_a_install_path_file="/tmp/openclaw-release-user-journey-plugin-a-install-path.txt"
|
||||
plugin_a_source_path_file="/tmp/openclaw-release-user-journey-plugin-a-source-path.txt"
|
||||
plugin_a_dir="$(mktemp -d "$scenario_tmp/plugin-a.XXXXXX")"
|
||||
plugin_a_install_path_file="$PLUGIN_A_INSTALL_PATH_FILE"
|
||||
plugin_a_source_path_file="$PLUGIN_A_SOURCE_PATH_FILE"
|
||||
write_journey_plugin "$plugin_a_dir" journey-plugin-a 0.0.1 journey.a "Journey Plugin A" journey-a "journey-plugin-a:pong"
|
||||
openclaw plugins install "$plugin_a_dir" >/tmp/openclaw-release-user-journey-plugin-a-install.log 2>&1
|
||||
openclaw plugins install "$plugin_a_dir" >"$PLUGIN_A_INSTALL_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs \
|
||||
remember-plugin-install-path \
|
||||
journey-plugin-a \
|
||||
"$plugin_a_install_path_file" \
|
||||
"$plugin_a_source_path_file" \
|
||||
"$plugin_a_dir"
|
||||
openclaw journey-a ping >/tmp/openclaw-release-user-journey-plugin-a-cli.log 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains /tmp/openclaw-release-user-journey-plugin-a-cli.log "journey-plugin-a:pong"
|
||||
openclaw journey-a ping >"$PLUGIN_A_CLI_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_A_CLI_LOG" "journey-plugin-a:pong"
|
||||
|
||||
echo "Uninstalling first external plugin..."
|
||||
openclaw plugins uninstall journey-plugin-a --force >/tmp/openclaw-release-user-journey-plugin-a-uninstall.log 2>&1
|
||||
openclaw plugins uninstall journey-plugin-a --force >"$PLUGIN_A_UNINSTALL_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs \
|
||||
assert-plugin-uninstalled \
|
||||
journey-plugin-a \
|
||||
@@ -186,41 +209,41 @@ node scripts/e2e/lib/release-user-journey/assertions.mjs \
|
||||
"$plugin_a_source_path_file"
|
||||
|
||||
echo "Installing replacement external plugin..."
|
||||
plugin_b_dir="$(mktemp -d "/tmp/openclaw-release-journey-plugin-b.XXXXXX")"
|
||||
plugin_b_dir="$(mktemp -d "$scenario_tmp/plugin-b.XXXXXX")"
|
||||
write_journey_plugin "$plugin_b_dir" journey-plugin-b 0.0.1 journey.b "Journey Plugin B" journey-b "journey-plugin-b:pong"
|
||||
openclaw plugins install "$plugin_b_dir" >/tmp/openclaw-release-user-journey-plugin-b-install.log 2>&1
|
||||
openclaw journey-b ping >/tmp/openclaw-release-user-journey-plugin-b-cli.log 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains /tmp/openclaw-release-user-journey-plugin-b-cli.log "journey-plugin-b:pong"
|
||||
openclaw plugins install "$plugin_b_dir" >"$PLUGIN_B_INSTALL_LOG" 2>&1
|
||||
openclaw journey-b ping >"$PLUGIN_B_CLI_LOG" 2>&1
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_CLI_LOG" "journey-plugin-b:pong"
|
||||
|
||||
echo "Configuring ClickClack..."
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs configure-clickclack "http://127.0.0.1:$CLICKCLACK_PORT"
|
||||
openclaw channels status --json >/tmp/openclaw-release-user-journey-status.json 2>/tmp/openclaw-release-user-journey-status.err
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack /tmp/openclaw-release-user-journey-status.json
|
||||
openclaw channels status --json >"$STATUS_JSON" 2>"$STATUS_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_JSON"
|
||||
|
||||
echo "Sending ClickClack outbound message..."
|
||||
openclaw message send \
|
||||
--channel clickclack \
|
||||
--target channel:general \
|
||||
--message "release journey outbound" \
|
||||
--json >/tmp/openclaw-release-user-journey-clickclack-outbound.json 2>/tmp/openclaw-release-user-journey-clickclack-outbound.err
|
||||
--json >"$CLICKCLACK_OUTBOUND_JSON" 2>"$CLICKCLACK_OUTBOUND_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-clickclack-state outbound "$CLICKCLACK_STATE" "release journey outbound"
|
||||
|
||||
echo "Starting Gateway for ClickClack inbound..."
|
||||
start_gateway /tmp/openclaw-release-user-journey-gateway-1.log
|
||||
start_gateway "$GATEWAY_1_LOG"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-socket "http://127.0.0.1:$CLICKCLACK_PORT" 45
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs post-clickclack-inbound "http://127.0.0.1:$CLICKCLACK_PORT" "Return marker $SUCCESS_MARKER"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs wait-clickclack-reply "$CLICKCLACK_STATE" "$SUCCESS_MARKER" 45
|
||||
|
||||
echo "Restarting Gateway and checking state survival..."
|
||||
stop_gateway
|
||||
start_gateway /tmp/openclaw-release-user-journey-gateway-2.log
|
||||
openclaw plugins inspect journey-plugin-b --runtime --json >/tmp/openclaw-release-user-journey-plugin-b-after-restart.json 2>&1
|
||||
openclaw channels status --json >/tmp/openclaw-release-user-journey-status-after-restart.json 2>/tmp/openclaw-release-user-journey-status-after-restart.err
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack /tmp/openclaw-release-user-journey-status-after-restart.json
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains /tmp/openclaw-release-user-journey-plugin-b-after-restart.json "journey-plugin-b"
|
||||
start_gateway "$GATEWAY_2_LOG"
|
||||
openclaw plugins inspect journey-plugin-b --runtime --json >"$PLUGIN_B_AFTER_RESTART_JSON" 2>&1
|
||||
openclaw channels status --json >"$STATUS_AFTER_RESTART_JSON" 2>"$STATUS_AFTER_RESTART_ERR"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-channel-status clickclack "$STATUS_AFTER_RESTART_JSON"
|
||||
node scripts/e2e/lib/release-user-journey/assertions.mjs assert-file-contains "$PLUGIN_B_AFTER_RESTART_JSON" "journey-plugin-b"
|
||||
stop_gateway
|
||||
|
||||
echo "Running doctor at end of release journey..."
|
||||
openclaw doctor --repair --non-interactive >/tmp/openclaw-release-user-journey-doctor.log 2>&1
|
||||
openclaw doctor --repair --non-interactive >"$DOCTOR_LOG" 2>&1
|
||||
|
||||
echo "Release user journey scenario passed."
|
||||
|
||||
@@ -24,8 +24,8 @@ export async function createE2eStateDir(prefix: string, env = process.env): Prom
|
||||
|
||||
const cleanup = () => {
|
||||
if (created && !cleaned) {
|
||||
cleaned = true;
|
||||
rmSync(stateDir, { force: true, recursive: true });
|
||||
cleaned = true;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,8 @@ source "$ROOT_DIR/scripts/lib/docker-e2e-package.sh"
|
||||
|
||||
IMAGE_NAME="openclaw-multi-node-update-e2e"
|
||||
DOCKER_RUN_TIMEOUT="${OPENCLAW_MULTI_NODE_DOCKER_TIMEOUT:-300s}"
|
||||
ARTIFACT_DIR="${OPENCLAW_MULTI_NODE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/multi-node-update}"
|
||||
RUN_ID="${OPENCLAW_MULTI_NODE_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
|
||||
ARTIFACT_DIR="${OPENCLAW_MULTI_NODE_ARTIFACT_DIR:-$ROOT_DIR/.artifacts/multi-node-update/$RUN_ID}"
|
||||
|
||||
mkdir -p "$ARTIFACT_DIR"
|
||||
chmod -R a+rwX "$ARTIFACT_DIR" || true
|
||||
|
||||
@@ -11,7 +11,8 @@ DOCKER_TARGET="${OPENCLAW_NPM_TELEGRAM_DOCKER_TARGET:-build}"
|
||||
PACKAGE_SPEC="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:-openclaw@beta}"
|
||||
PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
|
||||
PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}"
|
||||
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live}"
|
||||
RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
|
||||
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-live/$RUN_ID}"
|
||||
|
||||
resolve_credential_source() {
|
||||
if [ -n "${OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE:-}" ]; then
|
||||
|
||||
@@ -9,7 +9,8 @@ DOCKER_TARGET="${OPENCLAW_NPM_TELEGRAM_DOCKER_TARGET:-build}"
|
||||
PACKAGE_SPEC="${OPENCLAW_NPM_TELEGRAM_PACKAGE_SPEC:-openclaw@beta}"
|
||||
PACKAGE_TGZ="${OPENCLAW_NPM_TELEGRAM_PACKAGE_TGZ:-${OPENCLAW_CURRENT_PACKAGE_TGZ:-}}"
|
||||
PACKAGE_LABEL="${OPENCLAW_NPM_TELEGRAM_PACKAGE_LABEL:-}"
|
||||
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-rtt}"
|
||||
RUN_ID="${OPENCLAW_NPM_TELEGRAM_RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
|
||||
OUTPUT_DIR="${OPENCLAW_NPM_TELEGRAM_OUTPUT_DIR:-.artifacts/qa-e2e/npm-telegram-rtt/$RUN_ID}"
|
||||
|
||||
resolve_credential_source() {
|
||||
if [ -n "${OPENCLAW_NPM_TELEGRAM_CREDENTIAL_SOURCE:-}" ]; then
|
||||
@@ -387,12 +388,30 @@ installed_version="$(node -p "require('/npm-global/lib/node_modules/openclaw/pac
|
||||
|
||||
node /app/scripts/e2e/mock-openai-server.mjs >"$mock_log" 2>&1 &
|
||||
mock_pid="$!"
|
||||
mock_ready=0
|
||||
for _ in $(seq 1 60); do
|
||||
if node -e "fetch('http://127.0.0.1:${mock_port}/health').then((r)=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"; then
|
||||
if node --input-type=module -e '
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1000);
|
||||
try {
|
||||
const response = await fetch(process.argv[1], { signal: controller.signal });
|
||||
process.exit(response.ok ? 0 : 1);
|
||||
} catch {
|
||||
process.exit(1);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
' "http://127.0.0.1:${mock_port}/health"; then
|
||||
mock_ready=1
|
||||
break
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
if [ "$mock_ready" != "1" ]; then
|
||||
echo "Mock OpenAI server did not become ready" >&2
|
||||
cat "$mock_log" >&2 || true
|
||||
exit 1
|
||||
fi
|
||||
|
||||
mkdir -p "$(dirname "$config_path")" "$HOME/.openclaw/workspace" "$HOME/.openclaw/agents/main/sessions" "$HOME/workspace"
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import {
|
||||
parseBoolEnv,
|
||||
parseMode,
|
||||
parseProvider,
|
||||
readPositiveIntEnv,
|
||||
modelProviderConfigBatchJson,
|
||||
posixProviderOnlyPluginIsolationScript,
|
||||
repoRoot,
|
||||
@@ -234,6 +235,10 @@ function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
|
||||
class LinuxSmoke extends SmokeRunController<LinuxOptions> {
|
||||
private auth: ProviderAuth;
|
||||
private disableBonjour = parseBoolEnv(process.env.OPENCLAW_PARALLELS_LINUX_DISABLE_BONJOUR);
|
||||
private agentTimeoutSeconds = readPositiveIntEnv(
|
||||
"OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S",
|
||||
1500,
|
||||
);
|
||||
private artifact: PackageArtifact | null = null;
|
||||
private latestVersion = "";
|
||||
private snapshot!: SnapshotInfo;
|
||||
@@ -320,10 +325,8 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
|
||||
);
|
||||
await this.phase("fresh.gateway-status", 240, () => this.verifyGatewayStatus());
|
||||
this.status.freshGateway = "pass";
|
||||
await this.phase(
|
||||
"fresh.first-local-agent-turn",
|
||||
Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 1500),
|
||||
() => this.verifyLocalTurn(),
|
||||
await this.phase("fresh.first-local-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
this.verifyLocalTurn(),
|
||||
);
|
||||
this.status.freshAgent = "pass";
|
||||
}
|
||||
@@ -352,10 +355,8 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
|
||||
);
|
||||
await this.phase("upgrade.gateway-status", 240, () => this.verifyGatewayStatus());
|
||||
this.status.upgradeGateway = "pass";
|
||||
await this.phase(
|
||||
"upgrade.first-local-agent-turn",
|
||||
Number(process.env.OPENCLAW_PARALLELS_LINUX_AGENT_TIMEOUT_S || 1500),
|
||||
() => this.verifyLocalTurn(),
|
||||
await this.phase("upgrade.first-local-agent-turn", this.agentTimeoutSeconds, () =>
|
||||
this.verifyLocalTurn(),
|
||||
);
|
||||
this.status.upgradeAgent = "pass";
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
makeTempDir,
|
||||
parseMode,
|
||||
parseProvider,
|
||||
readPositiveIntEnv,
|
||||
resolveLatestVersion,
|
||||
resolveParallelsModelTimeoutSeconds,
|
||||
resolveWindowsProviderAuth,
|
||||
@@ -226,6 +227,16 @@ function stripLeadingPackageManagerSeparator(argv: string[]): string[] {
|
||||
|
||||
class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
||||
private auth: ProviderAuth;
|
||||
private agentTimeoutSeconds = readPositiveIntEnv(
|
||||
"OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S",
|
||||
2700,
|
||||
);
|
||||
private updateTimeoutSeconds = readPositiveIntEnv(
|
||||
"OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S",
|
||||
1200,
|
||||
);
|
||||
private gatewayRecoveryAfterMs =
|
||||
readPositiveIntEnv("OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S", 180) * 1000;
|
||||
private artifact: PackageArtifact | null = null;
|
||||
private minGitZipPath = "";
|
||||
private latestVersion = "";
|
||||
@@ -346,11 +357,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
||||
await this.phase("fresh.gateway-restart", 420, () => this.gatewayAction("restart"));
|
||||
await this.phase("fresh.gateway-status", 420, () => this.verifyGatewayReachable());
|
||||
this.status.freshGateway = "pass";
|
||||
await this.phase(
|
||||
"fresh.first-agent-turn",
|
||||
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 2700),
|
||||
() => this.verifyTurn(),
|
||||
);
|
||||
await this.phase("fresh.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
|
||||
this.status.freshAgent = "pass";
|
||||
}
|
||||
|
||||
@@ -392,10 +399,8 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
||||
this.status.upgradePrecheck = "latest-ref-fail";
|
||||
}
|
||||
await this.phase("upgrade.gateway-stop-before-update", 420, () => this.gatewayAction("stop"));
|
||||
await this.phase(
|
||||
"upgrade.update-dev",
|
||||
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S || 1200),
|
||||
() => this.runDevChannelUpdate(),
|
||||
await this.phase("upgrade.update-dev", this.updateTimeoutSeconds, () =>
|
||||
this.runDevChannelUpdate(),
|
||||
);
|
||||
this.status.upgradeVersion = await this.extractLastVersion("upgrade.update-dev");
|
||||
await this.phase("upgrade.verify-dev-channel", 120, () => this.verifyDevChannelUpdate());
|
||||
@@ -404,11 +409,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
|
||||
await this.phase("upgrade.gateway-restart", 420, () => this.gatewayAction("restart"));
|
||||
await this.phase("upgrade.gateway-status", 420, () => this.verifyGatewayReachable());
|
||||
this.status.upgradeGateway = "pass";
|
||||
await this.phase(
|
||||
"upgrade.first-agent-turn",
|
||||
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 2700),
|
||||
() => this.verifyTurn(),
|
||||
);
|
||||
await this.phase("upgrade.first-agent-turn", this.agentTimeoutSeconds, () => this.verifyTurn());
|
||||
this.status.upgradeAgent = "pass";
|
||||
}
|
||||
|
||||
@@ -645,7 +646,7 @@ Invoke-WithScopedEnv @{ OPENCLAW_ALLOW_OLDER_BINARY_DESTRUCTIVE_ACTIONS = '1'; O
|
||||
if ($script:OpenClawUpdateExit -ne 0) { throw "openclaw update failed with exit code $script:OpenClawUpdateExit" }
|
||||
Invoke-OpenClaw --version
|
||||
Invoke-OpenClaw update status --json`,
|
||||
{ timeoutMs: Number(process.env.OPENCLAW_PARALLELS_WINDOWS_UPDATE_TIMEOUT_S || 1200) * 1000 },
|
||||
{ timeoutMs: this.updateTimeoutSeconds * 1000 },
|
||||
);
|
||||
}
|
||||
|
||||
@@ -676,8 +677,6 @@ if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTE
|
||||
const deadline = Date.now() + 420_000;
|
||||
let attempt = 1;
|
||||
let recoveryTried = false;
|
||||
const recoveryAfter =
|
||||
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_GATEWAY_RECOVERY_AFTER_S || 180) * 1000;
|
||||
const start = Date.now();
|
||||
while (Date.now() < deadline) {
|
||||
const probe = this.guestPowerShell(
|
||||
@@ -687,7 +686,7 @@ if ($LASTEXITCODE -ne 0) { throw "gateway ${action} failed with exit code $LASTE
|
||||
if (/"ok"\s*:\s*true/.test(probe)) {
|
||||
return;
|
||||
}
|
||||
if (!recoveryTried && Date.now() - start >= recoveryAfter) {
|
||||
if (!recoveryTried && Date.now() - start >= this.gatewayRecoveryAfterMs) {
|
||||
warn(
|
||||
`gateway-reachable recovery: gateway start after ${Math.floor((Date.now() - start) / 1000)}s`,
|
||||
);
|
||||
@@ -759,7 +758,7 @@ for ($attempt = 1; $attempt -le 2; $attempt++) {
|
||||
}
|
||||
}
|
||||
if (-not $agentOk) { throw 'openclaw agent finished without OK response' }`,
|
||||
Number(process.env.OPENCLAW_PARALLELS_WINDOWS_AGENT_TIMEOUT_S || 2700) * 1000,
|
||||
this.agentTimeoutSeconds * 1000,
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -203,19 +203,26 @@ function makeEnv(name) {
|
||||
return { root, home, stateDir, env };
|
||||
}
|
||||
|
||||
async function cleanupEnv(root) {
|
||||
async function cleanupEnv(root, options = {}) {
|
||||
if (process.env.OPENCLAW_SECRET_PROOF_KEEP_TMP === "1") {
|
||||
console.log(`[keep] ${root}`);
|
||||
return;
|
||||
}
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const attempts = options.attempts ?? 5;
|
||||
const retryDelayMs = options.retryDelayMs ?? 250;
|
||||
let lastError;
|
||||
for (let attempt = 0; attempt < attempts; attempt += 1) {
|
||||
try {
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
return;
|
||||
} catch {
|
||||
await delay(250);
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
if (attempt < attempts - 1) {
|
||||
await delay(retryDelayMs);
|
||||
}
|
||||
}
|
||||
}
|
||||
throw new Error(`failed to remove secret proof temp root ${root}`, { cause: lastError });
|
||||
}
|
||||
|
||||
function runCommand(command, args, options = {}) {
|
||||
@@ -1728,6 +1735,7 @@ async function main() {
|
||||
}
|
||||
|
||||
export {
|
||||
cleanupEnv,
|
||||
expectGatewayStartupFails,
|
||||
gatewayCall,
|
||||
runCommand,
|
||||
|
||||
@@ -12,11 +12,17 @@ const DEFAULT_BUILD_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function positiveEnvInt(name, env, fallback) {
|
||||
const raw = env[name]?.trim();
|
||||
if (raw === undefined || raw === "" || !/^[0-9]+$/.test(raw)) {
|
||||
if (raw === undefined || raw === "") {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
if (!/^[1-9]\d*$/.test(raw)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveCliStartupBuildTimeoutMs(env = process.env) {
|
||||
|
||||
@@ -14,11 +14,17 @@ const DEFAULT_BUILD_TIMEOUT_MS = 10 * 60 * 1000;
|
||||
|
||||
function positiveEnvInt(name, env, fallback) {
|
||||
const raw = env[name]?.trim();
|
||||
if (raw === undefined || raw === "" || !/^[0-9]+$/.test(raw)) {
|
||||
if (raw === undefined || raw === "") {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
if (!/^[1-9]\d*$/.test(raw)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveExtensionMemoryBuildTimeoutMs(env = process.env) {
|
||||
|
||||
@@ -73,6 +73,15 @@ docker_build_timeout_required() {
|
||||
return 1
|
||||
}
|
||||
|
||||
docker_build_heartbeat_seconds() {
|
||||
local configured="${OPENCLAW_DOCKER_BUILD_HEARTBEAT_SECONDS:-30}"
|
||||
if [[ "$configured" =~ ^[0-9]+$ ]] && [ "$configured" -ge 1 ]; then
|
||||
echo "$((10#$configured))"
|
||||
return
|
||||
fi
|
||||
echo 30
|
||||
}
|
||||
|
||||
docker_build_run_command() {
|
||||
local timeout_value="$1"
|
||||
shift
|
||||
@@ -85,6 +94,37 @@ docker_build_run_command() {
|
||||
"$@"
|
||||
}
|
||||
|
||||
docker_build_run_logged() {
|
||||
local label="$1"
|
||||
local timeout_value="$2"
|
||||
local log_file="$3"
|
||||
shift 3
|
||||
local heartbeat_seconds
|
||||
heartbeat_seconds="$(docker_build_heartbeat_seconds)"
|
||||
local started_at="$SECONDS"
|
||||
local next_heartbeat=$heartbeat_seconds
|
||||
local build_status=0
|
||||
|
||||
docker_build_run_command "$timeout_value" "$@" >"$log_file" 2>&1 &
|
||||
local build_pid="$!"
|
||||
while kill -0 "$build_pid" 2>/dev/null; do
|
||||
/bin/sleep 1
|
||||
local elapsed_seconds=$((SECONDS - started_at))
|
||||
if [ "$elapsed_seconds" -ge "$next_heartbeat" ] && kill -0 "$build_pid" 2>/dev/null; then
|
||||
local log_bytes="0"
|
||||
if [ -f "$log_file" ]; then
|
||||
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
|
||||
log_bytes="${log_bytes//[[:space:]]/}"
|
||||
fi
|
||||
echo "Docker build $label still running (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)..."
|
||||
next_heartbeat=$((elapsed_seconds + heartbeat_seconds))
|
||||
fi
|
||||
done
|
||||
|
||||
wait "$build_pid" || build_status="$?"
|
||||
return "$build_status"
|
||||
}
|
||||
|
||||
docker_build_with_retries() {
|
||||
local label="$1"
|
||||
shift
|
||||
@@ -101,7 +141,7 @@ docker_build_with_retries() {
|
||||
local timeout_value="${OPENCLAW_DOCKER_BUILD_TIMEOUT:-3600s}"
|
||||
while true; do
|
||||
log_file="$(docker_e2e_run_log "$label")"
|
||||
if docker_build_run_command "$timeout_value" "${command[@]}" >"$log_file" 2>&1; then
|
||||
if docker_build_run_logged "$label" "$timeout_value" "$log_file" "${command[@]}"; then
|
||||
rm -f "$log_file"
|
||||
return 0
|
||||
fi
|
||||
@@ -116,7 +156,7 @@ docker_build_with_retries() {
|
||||
docker_e2e_print_log "$log_file"
|
||||
rm -f "$log_file"
|
||||
attempt=$((attempt + 1))
|
||||
sleep "$attempt"
|
||||
/bin/sleep "$attempt"
|
||||
done
|
||||
}
|
||||
|
||||
|
||||
@@ -35,19 +35,29 @@ run_logged_print_heartbeat() {
|
||||
local label="$1"
|
||||
local interval_seconds="$2"
|
||||
shift 2
|
||||
if ! [[ "$interval_seconds" =~ ^[0-9]+$ ]] || [ "$interval_seconds" -lt 1 ]; then
|
||||
interval_seconds="30"
|
||||
else
|
||||
interval_seconds="$((10#$interval_seconds))"
|
||||
fi
|
||||
local log_file
|
||||
log_file="$(docker_e2e_run_log "$label")"
|
||||
"$@" >"$log_file" 2>&1 &
|
||||
local command_pid=$!
|
||||
local started_at
|
||||
started_at="$(date +%s)"
|
||||
local started_at="$SECONDS"
|
||||
local next_heartbeat=$interval_seconds
|
||||
local status=0
|
||||
while kill -0 "$command_pid" 2>/dev/null; do
|
||||
sleep "$interval_seconds"
|
||||
if kill -0 "$command_pid" 2>/dev/null; then
|
||||
local now
|
||||
now="$(date +%s)"
|
||||
echo "still running $label ($((now - started_at))s elapsed)"
|
||||
/bin/sleep 1
|
||||
local elapsed_seconds=$((SECONDS - started_at))
|
||||
if [ "$elapsed_seconds" -ge "$next_heartbeat" ] && kill -0 "$command_pid" 2>/dev/null; then
|
||||
local log_bytes="0"
|
||||
if [ -f "$log_file" ]; then
|
||||
log_bytes="$(wc -c <"$log_file" 2>/dev/null || echo 0)"
|
||||
log_bytes="${log_bytes//[[:space:]]/}"
|
||||
fi
|
||||
echo "still running $label (${elapsed_seconds}s elapsed, ${log_bytes} log bytes captured)"
|
||||
next_heartbeat=$((elapsed_seconds + interval_seconds))
|
||||
fi
|
||||
done
|
||||
set +e
|
||||
|
||||
@@ -10,12 +10,18 @@ const DEFAULT_NPM_VERIFY_COMMAND_TIMEOUT_MS = 5 * 60 * 1000;
|
||||
const DEFAULT_NPM_VERIFY_COMMAND_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
||||
|
||||
function positiveEnvInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name];
|
||||
const raw = process.env[name]?.trim();
|
||||
if (raw === undefined || raw === "") {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
if (!/^[1-9]\d*$/u.test(raw)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function runNpmVerifyCommand(
|
||||
|
||||
@@ -298,21 +298,63 @@ openclaw_e2e_run_script_with_pty() {
|
||||
openclaw_e2e_maybe_timeout "$timeout_value" script -q -F "$log_path" /bin/bash -lc "$command"
|
||||
fi
|
||||
}
|
||||
openclaw_e2e_start_tracked_process() {
|
||||
local log_path="${1:?missing OpenClaw E2E process log path}"
|
||||
shift
|
||||
if command -v setsid >/dev/null 2>&1; then
|
||||
setsid "$@" >"$log_path" 2>&1 &
|
||||
printf '%s\n' "$!"
|
||||
return
|
||||
fi
|
||||
node --input-type=module - "$log_path" "$@" <<'NODE'
|
||||
import { closeSync, openSync } from "node:fs";
|
||||
import { spawn } from "node:child_process";
|
||||
|
||||
const [logPath, command, ...args] = process.argv.slice(2);
|
||||
if (!command) {
|
||||
console.error("missing command for OpenClaw E2E tracked process");
|
||||
process.exit(1);
|
||||
}
|
||||
const logFd = openSync(logPath, "a");
|
||||
const child = spawn(command, args, {
|
||||
detached: process.platform !== "win32",
|
||||
env: process.env,
|
||||
stdio: ["ignore", logFd, logFd],
|
||||
});
|
||||
closeSync(logFd);
|
||||
child.unref();
|
||||
console.log(child.pid);
|
||||
NODE
|
||||
}
|
||||
openclaw_e2e_signal_process() {
|
||||
local pid="${1:-}" signal="${2:-TERM}"
|
||||
[ -n "$pid" ] || return 0
|
||||
if kill -0 -- "-$pid" >/dev/null 2>&1; then
|
||||
kill "-$signal" -- "-$pid" >/dev/null 2>&1 || true
|
||||
return 0
|
||||
fi
|
||||
kill "-$signal" "$pid" >/dev/null 2>&1 || true
|
||||
}
|
||||
openclaw_e2e_process_alive() {
|
||||
local pid="${1:-}"
|
||||
[ -n "$pid" ] || return 1
|
||||
kill -0 "$pid" >/dev/null 2>&1 || kill -0 -- "-$pid" >/dev/null 2>&1
|
||||
}
|
||||
openclaw_e2e_stop_process() {
|
||||
local pid="${1:-}" _
|
||||
[ -n "$pid" ] || return 0
|
||||
kill "$pid" >/dev/null 2>&1 || true
|
||||
openclaw_e2e_signal_process "$pid" TERM
|
||||
for _ in $(seq 1 40); do
|
||||
! kill -0 "$pid" >/dev/null 2>&1 && { wait "$pid" >/dev/null 2>&1 || true; return 0; }
|
||||
! openclaw_e2e_process_alive "$pid" && { wait "$pid" >/dev/null 2>&1 || true; return 0; }
|
||||
sleep 0.25
|
||||
done
|
||||
kill -9 "$pid" >/dev/null 2>&1 || true
|
||||
openclaw_e2e_signal_process "$pid" KILL
|
||||
wait "$pid" >/dev/null 2>&1 || true
|
||||
}
|
||||
openclaw_e2e_terminate_gateways() {
|
||||
openclaw_e2e_stop_process "${1:-}"
|
||||
}
|
||||
openclaw_e2e_start_mock_openai() { MOCK_PORT="$1" node scripts/e2e/mock-openai-server.mjs >"$2" 2>&1 & printf '%s\n' "$!"; }
|
||||
openclaw_e2e_start_mock_openai() { openclaw_e2e_start_tracked_process "$2" env "MOCK_PORT=$1" node scripts/e2e/mock-openai-server.mjs; }
|
||||
openclaw_e2e_wait_mock_openai() {
|
||||
local port="$1" attempts="${2:-80}" timeout_ms="${3:-400}" _
|
||||
for _ in $(seq 1 "$attempts"); do
|
||||
@@ -321,7 +363,7 @@ openclaw_e2e_wait_mock_openai() {
|
||||
done
|
||||
openclaw_e2e_probe_http "http://127.0.0.1:${port}/health" ok "$timeout_ms"
|
||||
}
|
||||
openclaw_e2e_start_gateway() { node "$1" gateway --port "$2" --bind loopback --allow-unconfigured >"$3" 2>&1 & printf '%s\n' "$!"; }
|
||||
openclaw_e2e_start_gateway() { openclaw_e2e_start_tracked_process "$3" node "$1" gateway --port "$2" --bind loopback --allow-unconfigured; }
|
||||
openclaw_e2e_exec_gateway() { exec node "$1" gateway --port "$2" --bind "${3:-loopback}" --allow-unconfigured >"$4" 2>&1; }
|
||||
openclaw_e2e_wait_gateway_ready() {
|
||||
local pid="$1" log="$2" attempts="${3:-300}" _
|
||||
|
||||
@@ -0,0 +1,565 @@
|
||||
import { spawn } from "node:child_process";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import { createRequire } from "node:module";
|
||||
import net from "node:net";
|
||||
import path from "node:path";
|
||||
import { performance } from "node:perf_hooks";
|
||||
import { fileURLToPath, pathToFileURL } from "node:url";
|
||||
|
||||
const DEFAULT_METHODS = ["health", "config.get"];
|
||||
const DEFAULT_ITERATIONS = 10;
|
||||
export const READY_TIMEOUT_MS = 120_000;
|
||||
export const READY_PROBE_TIMEOUT_MS = 1_000;
|
||||
const IS_DIRECT_RUN =
|
||||
typeof process.argv[1] === "string" &&
|
||||
path.resolve(process.argv[1]) === fileURLToPath(import.meta.url);
|
||||
|
||||
function usage() {
|
||||
return [
|
||||
"Usage: node --import tsx scripts/measure-rpc-rtt.mjs",
|
||||
" --output-dir <dir>",
|
||||
" [--repo-root <openclaw-repo>]",
|
||||
" [--iterations <count>]",
|
||||
" [--methods <comma-separated-methods>]",
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = {
|
||||
iterations: DEFAULT_ITERATIONS,
|
||||
methods: DEFAULT_METHODS,
|
||||
};
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (arg === "--output-dir") {
|
||||
args.outputDir = argv[(index += 1)];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--repo-root") {
|
||||
args.repoRoot = argv[(index += 1)];
|
||||
continue;
|
||||
}
|
||||
if (arg === "--iterations") {
|
||||
args.iterations = Number(argv[(index += 1)]);
|
||||
continue;
|
||||
}
|
||||
if (arg === "--methods") {
|
||||
args.methods = argv[(index += 1)]
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
continue;
|
||||
}
|
||||
throw new Error(`Unknown argument: ${arg}\n${usage()}`);
|
||||
}
|
||||
if (!args.outputDir) {
|
||||
throw new Error(usage());
|
||||
}
|
||||
if (!Number.isInteger(args.iterations) || args.iterations < 1) {
|
||||
throw new Error("--iterations must be a positive integer.");
|
||||
}
|
||||
if (args.methods.length === 0) {
|
||||
throw new Error("--methods must include at least one gateway method.");
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
async function getFreePort() {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const server = net.createServer();
|
||||
server.on("error", reject);
|
||||
server.listen(0, "127.0.0.1", () => {
|
||||
const address = server.address();
|
||||
server.close(() => {
|
||||
if (address && typeof address === "object") {
|
||||
resolve(address.port);
|
||||
return;
|
||||
}
|
||||
reject(new Error("failed to allocate loopback port"));
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function sleep(ms) {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function formatErrorMessage(error) {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
return String(error);
|
||||
}
|
||||
|
||||
export async function waitForGatewayReady({
|
||||
child,
|
||||
fetchImpl = fetch,
|
||||
port,
|
||||
probeTimeoutMs = READY_PROBE_TIMEOUT_MS,
|
||||
readyTimeoutMs = READY_TIMEOUT_MS,
|
||||
sleepMs = 250,
|
||||
stderrPath,
|
||||
}) {
|
||||
const startedAt = Date.now();
|
||||
let childExit = null;
|
||||
child.once("exit", (code, signal) => {
|
||||
childExit = { code, signal };
|
||||
});
|
||||
const getChildExit = () =>
|
||||
childExit ??
|
||||
(child.exitCode != null || child.signalCode != null
|
||||
? { code: child.exitCode, signal: child.signalCode }
|
||||
: null);
|
||||
while (Date.now() - startedAt < readyTimeoutMs) {
|
||||
const observedExit = getChildExit();
|
||||
if (observedExit) {
|
||||
const stderr = await fs.readFile(stderrPath, "utf8").catch(() => "");
|
||||
throw new Error(
|
||||
`gateway exited before readiness code=${observedExit.code ?? "null"} signal=${observedExit.signal ?? "null"}\n${stderr.slice(-4000)}`,
|
||||
);
|
||||
}
|
||||
for (const endpoint of ["/readyz", "/healthz"]) {
|
||||
try {
|
||||
const response = await fetchImpl(`http://127.0.0.1:${port}${endpoint}`, {
|
||||
signal: AbortSignal.timeout(probeTimeoutMs),
|
||||
});
|
||||
if (response.ok) {
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// The gateway may not have bound the port yet.
|
||||
}
|
||||
}
|
||||
await sleep(sleepMs);
|
||||
}
|
||||
const stderr = await fs.readFile(stderrPath, "utf8").catch(() => "");
|
||||
throw new Error(`gateway did not become ready after ${readyTimeoutMs}ms\n${stderr.slice(-4000)}`);
|
||||
}
|
||||
|
||||
async function stopGateway(child) {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return;
|
||||
}
|
||||
child.kill("SIGTERM");
|
||||
const exited = await new Promise((resolve) => {
|
||||
const timer = setTimeout(() => resolve(false), 1_500);
|
||||
child.once("exit", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(true);
|
||||
});
|
||||
});
|
||||
if (!exited && child.exitCode === null && child.signalCode === null) {
|
||||
child.kill("SIGKILL");
|
||||
}
|
||||
}
|
||||
|
||||
async function closeFileHandles(handles) {
|
||||
const results = await Promise.allSettled(handles.filter(Boolean).map((handle) => handle.close()));
|
||||
const failedClose = results.find((result) => result.status === "rejected");
|
||||
if (failedClose) {
|
||||
throw failedClose.reason;
|
||||
}
|
||||
}
|
||||
|
||||
export async function startGateway({
|
||||
configPath,
|
||||
env = process.env,
|
||||
openImpl = fs.open,
|
||||
port,
|
||||
repoRoot,
|
||||
spawnImpl = spawn,
|
||||
stderrPath,
|
||||
stdoutPath,
|
||||
tempRoot,
|
||||
token,
|
||||
}) {
|
||||
const stdout = await openImpl(stdoutPath, "w");
|
||||
let stderr;
|
||||
try {
|
||||
stderr = await openImpl(stderrPath, "w");
|
||||
} catch (error) {
|
||||
try {
|
||||
await closeFileHandles([stdout]);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
|
||||
let child;
|
||||
try {
|
||||
child = spawnImpl(
|
||||
"pnpm",
|
||||
[
|
||||
"openclaw",
|
||||
"gateway",
|
||||
"run",
|
||||
"--port",
|
||||
String(port),
|
||||
"--bind",
|
||||
"loopback",
|
||||
"--allow-unconfigured",
|
||||
],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env: {
|
||||
...env,
|
||||
HOME: path.join(tempRoot, "home"),
|
||||
XDG_CONFIG_HOME: path.join(tempRoot, "xdg-config"),
|
||||
XDG_DATA_HOME: path.join(tempRoot, "xdg-data"),
|
||||
XDG_CACHE_HOME: path.join(tempRoot, "xdg-cache"),
|
||||
OPENCLAW_CONFIG_PATH: configPath,
|
||||
OPENCLAW_STATE_DIR: path.join(tempRoot, "state"),
|
||||
OPENCLAW_GATEWAY_TOKEN: token,
|
||||
OPENCLAW_SKIP_BROWSER_CONTROL_SERVER: "1",
|
||||
OPENCLAW_SKIP_GMAIL_WATCHER: "1",
|
||||
OPENCLAW_SKIP_CANVAS_HOST: "1",
|
||||
OPENCLAW_NO_RESPAWN: "1",
|
||||
OPENCLAW_TEST_FAST: "1",
|
||||
},
|
||||
stdio: ["ignore", stdout.fd, stderr.fd],
|
||||
},
|
||||
);
|
||||
} catch (error) {
|
||||
try {
|
||||
await closeFileHandles([stdout, stderr]);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
|
||||
try {
|
||||
await closeFileHandles([stdout, stderr]);
|
||||
} catch (error) {
|
||||
try {
|
||||
await stopGateway(child);
|
||||
} catch {}
|
||||
throw error;
|
||||
}
|
||||
|
||||
return child;
|
||||
}
|
||||
|
||||
export async function cleanupTempRoot(tempRoot, { rmImpl = fs.rm } = {}) {
|
||||
try {
|
||||
await rmImpl(tempRoot, { force: true, recursive: true });
|
||||
} catch (error) {
|
||||
throw new Error(`failed to remove RPC RTT temp root: ${formatErrorMessage(error)}`, {
|
||||
cause: error,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function quantile(sorted, q) {
|
||||
return sorted[Math.min(sorted.length - 1, Math.max(0, Math.ceil(sorted.length * q) - 1))];
|
||||
}
|
||||
|
||||
function stats(samples) {
|
||||
const sorted = samples.toSorted((left, right) => left - right);
|
||||
return {
|
||||
avgMs: Math.round(sorted.reduce((sum, value) => sum + value, 0) / sorted.length),
|
||||
maxMs: Math.round(sorted.at(-1)),
|
||||
minMs: Math.round(sorted[0]),
|
||||
p50Ms: Math.round(quantile(sorted, 0.5)),
|
||||
p95Ms: Math.round(quantile(sorted, 0.95)),
|
||||
};
|
||||
}
|
||||
|
||||
function toText(data) {
|
||||
if (typeof data === "string") {
|
||||
return data;
|
||||
}
|
||||
if (data instanceof ArrayBuffer) {
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
if (Array.isArray(data)) {
|
||||
return Buffer.concat(data.map((chunk) => Buffer.from(chunk))).toString("utf8");
|
||||
}
|
||||
return Buffer.from(data).toString("utf8");
|
||||
}
|
||||
|
||||
function createGatewayClient({ WebSocket, url }) {
|
||||
const ws = new WebSocket(url, { handshakeTimeout: 8_000 });
|
||||
const pending = new Map();
|
||||
const rejectPending = (error) => {
|
||||
for (const waiter of pending.values()) {
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.reject(error);
|
||||
}
|
||||
pending.clear();
|
||||
};
|
||||
ws.on("message", (data) => {
|
||||
let frame;
|
||||
try {
|
||||
frame = JSON.parse(toText(data));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
if (frame?.type === "res") {
|
||||
const waiter = pending.get(frame.id);
|
||||
if (!waiter) {
|
||||
return;
|
||||
}
|
||||
pending.delete(frame.id);
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.resolve(frame);
|
||||
}
|
||||
});
|
||||
ws.on("close", (code, reason) => {
|
||||
rejectPending(new Error(`gateway websocket closed (${code}): ${toText(reason)}`));
|
||||
});
|
||||
ws.on("error", (error) => {
|
||||
rejectPending(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
const waitOpen = async () =>
|
||||
await new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(() => reject(new Error("gateway websocket open timeout")), 8_000);
|
||||
ws.once("open", () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
});
|
||||
ws.once("error", (error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
});
|
||||
const request = async (method, params, timeoutMs = 10_000) =>
|
||||
await new Promise((resolve, reject) => {
|
||||
if (ws.readyState !== WebSocket.OPEN) {
|
||||
reject(new Error(`gateway websocket is not open for ${method}`));
|
||||
return;
|
||||
}
|
||||
const id = randomUUID();
|
||||
const timeout = setTimeout(() => {
|
||||
pending.delete(id);
|
||||
reject(new Error(`timeout waiting for ${method}`));
|
||||
}, timeoutMs);
|
||||
pending.set(id, { resolve, reject, timeout });
|
||||
ws.send(JSON.stringify({ type: "req", id, method, params }), (error) => {
|
||||
if (!error) {
|
||||
return;
|
||||
}
|
||||
const waiter = pending.get(id);
|
||||
if (!waiter) {
|
||||
return;
|
||||
}
|
||||
pending.delete(id);
|
||||
clearTimeout(waiter.timeout);
|
||||
waiter.reject(error instanceof Error ? error : new Error(String(error)));
|
||||
});
|
||||
});
|
||||
const close = () => {
|
||||
rejectPending(new Error("gateway websocket client closed"));
|
||||
ws.close();
|
||||
};
|
||||
return { close, request, waitOpen };
|
||||
}
|
||||
|
||||
async function writeSummary({
|
||||
details,
|
||||
events,
|
||||
finishedAt,
|
||||
outputDir,
|
||||
measurement,
|
||||
startedAt,
|
||||
status,
|
||||
}) {
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(outputDir, "rpc-events.json"),
|
||||
`${JSON.stringify(events, null, 2)}\n`,
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(outputDir, "qa-suite-summary.json"),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
counts: {
|
||||
total: 1,
|
||||
passed: status === "pass" ? 1 : 0,
|
||||
failed: status === "pass" ? 0 : 1,
|
||||
},
|
||||
run: {
|
||||
startedAt: startedAt.toISOString(),
|
||||
finishedAt: finishedAt.toISOString(),
|
||||
providerMode: "gateway-rpc",
|
||||
scenarioIds: ["rpc-gateway-smoke"],
|
||||
},
|
||||
scenarios: [
|
||||
{
|
||||
id: "rpc-gateway-smoke",
|
||||
title: "Gateway RPC loopback smoke",
|
||||
status,
|
||||
details,
|
||||
...(measurement ? { rttMeasurement: measurement } : {}),
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const repoRoot = path.resolve(args.repoRoot ?? process.env.OPENCLAW_REPO_ROOT ?? process.cwd());
|
||||
const outputDir = path.resolve(args.outputDir);
|
||||
await fs.mkdir(outputDir, { recursive: true });
|
||||
const tempRoot = await fs.mkdtemp(path.join(outputDir, "..", ".rpc-rtt-"));
|
||||
const startedAt = new Date();
|
||||
const token = `rpc-rtt-${randomUUID()}`;
|
||||
const port = await getFreePort();
|
||||
const configPath = path.join(tempRoot, "openclaw.json");
|
||||
const stdoutPath = path.join(tempRoot, "gateway.stdout.log");
|
||||
const stderrPath = path.join(tempRoot, "gateway.stderr.log");
|
||||
let gatewayChild;
|
||||
let status = "fail";
|
||||
let details = "";
|
||||
let measurement;
|
||||
let cleanupError;
|
||||
const events = [];
|
||||
try {
|
||||
await fs.writeFile(
|
||||
configPath,
|
||||
`${JSON.stringify(
|
||||
{
|
||||
gateway: {
|
||||
mode: "local",
|
||||
bind: "loopback",
|
||||
port,
|
||||
auth: { mode: "token", token },
|
||||
controlUi: { enabled: false },
|
||||
},
|
||||
plugins: { enabled: false },
|
||||
},
|
||||
null,
|
||||
2,
|
||||
)}\n`,
|
||||
);
|
||||
gatewayChild = await startGateway({
|
||||
configPath,
|
||||
port,
|
||||
repoRoot,
|
||||
stderrPath,
|
||||
stdoutPath,
|
||||
tempRoot,
|
||||
token,
|
||||
});
|
||||
await waitForGatewayReady({ child: gatewayChild, port, stderrPath });
|
||||
|
||||
const requireFromOpenClaw = createRequire(path.join(repoRoot, "package.json"));
|
||||
const WebSocket = requireFromOpenClaw("ws");
|
||||
const protocol = await import(
|
||||
pathToFileURL(path.join(repoRoot, "packages/gateway-protocol/src/version.ts")).href
|
||||
);
|
||||
const client = createGatewayClient({ WebSocket, url: `ws://127.0.0.1:${port}` });
|
||||
await client.waitOpen();
|
||||
const connectStarted = performance.now();
|
||||
const connect = await client.request(
|
||||
"connect",
|
||||
{
|
||||
minProtocol: protocol.MIN_CLIENT_PROTOCOL_VERSION,
|
||||
maxProtocol: protocol.PROTOCOL_VERSION,
|
||||
client: {
|
||||
id: "gateway-client",
|
||||
displayName: "openclaw-rtt rpc probe",
|
||||
version: "rtt",
|
||||
platform: process.platform,
|
||||
mode: "backend",
|
||||
instanceId: `openclaw-rtt-rpc-${randomUUID()}`,
|
||||
},
|
||||
locale: "en-US",
|
||||
userAgent: "openclaw-rtt-rpc",
|
||||
role: "operator",
|
||||
scopes: ["operator.admin"],
|
||||
caps: [],
|
||||
auth: { token },
|
||||
},
|
||||
10_000,
|
||||
);
|
||||
if (!connect.ok) {
|
||||
throw new Error(`connect failed: ${JSON.stringify(connect.error)}`);
|
||||
}
|
||||
events.push({
|
||||
event: "gateway-rpc.connect",
|
||||
payload: {
|
||||
method: "connect",
|
||||
ok: true,
|
||||
durationMs: Math.round(performance.now() - connectStarted),
|
||||
},
|
||||
});
|
||||
const samples = [];
|
||||
for (const method of args.methods) {
|
||||
for (let iteration = 1; iteration <= args.iterations; iteration += 1) {
|
||||
const requestStartedAtMs = performance.now();
|
||||
const response = await client.request(method, {}, 10_000);
|
||||
const durationMs = Math.round(performance.now() - requestStartedAtMs);
|
||||
if (!response.ok) {
|
||||
throw new Error(`${method} failed: ${JSON.stringify(response.error)}`);
|
||||
}
|
||||
samples.push({ method, durationMs });
|
||||
events.push({
|
||||
event: "gateway-rpc",
|
||||
payload: { kind: "gateway-rpc", method, ok: true, durationMs, iteration },
|
||||
});
|
||||
}
|
||||
}
|
||||
client.close();
|
||||
const sampleStats = stats(samples.map((sample) => sample.durationMs));
|
||||
const byMethod = Object.fromEntries(
|
||||
args.methods.map((method) => [
|
||||
method,
|
||||
stats(
|
||||
samples.filter((sample) => sample.method === method).map((sample) => sample.durationMs),
|
||||
),
|
||||
]),
|
||||
);
|
||||
measurement = {
|
||||
finalMatchedReplyRttMs: sampleStats.p50Ms,
|
||||
durationMs: sampleStats.p50Ms,
|
||||
method: args.methods.join(","),
|
||||
source: "gateway-rpc",
|
||||
};
|
||||
details = JSON.stringify({
|
||||
iterations: args.iterations,
|
||||
methods: args.methods,
|
||||
stats: sampleStats,
|
||||
byMethod,
|
||||
});
|
||||
status = "pass";
|
||||
} catch (error) {
|
||||
details = error instanceof Error ? (error.stack ?? error.message) : String(error);
|
||||
} finally {
|
||||
if (gatewayChild) {
|
||||
await stopGateway(gatewayChild).catch(() => {});
|
||||
}
|
||||
try {
|
||||
await cleanupTempRoot(tempRoot);
|
||||
} catch (error) {
|
||||
cleanupError = error;
|
||||
}
|
||||
}
|
||||
if (cleanupError) {
|
||||
const cleanupDetails = formatErrorMessage(cleanupError);
|
||||
details = details ? `${details}\n${cleanupDetails}` : cleanupDetails;
|
||||
status = "fail";
|
||||
}
|
||||
const finishedAt = new Date();
|
||||
await writeSummary({ details, events, finishedAt, outputDir, measurement, startedAt, status });
|
||||
if (status !== "pass") {
|
||||
throw new Error(details || "RPC RTT measurement failed");
|
||||
}
|
||||
}
|
||||
|
||||
if (IS_DIRECT_RUN) {
|
||||
main().catch(
|
||||
/** @param {unknown} error */ (error) => {
|
||||
process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
|
||||
process.exitCode = 1;
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -153,6 +153,10 @@ const OMITTED_QA_EXTENSION_PREFIXES = [
|
||||
];
|
||||
export const CROSS_OS_DASHBOARD_SMOKE_TIMEOUT_MS = 120_000;
|
||||
export const CROSS_OS_DASHBOARD_FETCH_TIMEOUT_MS = 10_000;
|
||||
export const CROSS_OS_DISCORD_FETCH_TIMEOUT_MS = parsePositiveIntegerEnv(
|
||||
"OPENCLAW_CROSS_OS_DISCORD_FETCH_TIMEOUT_MS",
|
||||
10_000,
|
||||
);
|
||||
export const CROSS_OS_FETCH_BODY_MAX_CHARS = 1024 * 1024;
|
||||
export const CROSS_OS_GATEWAY_STATUS_RPC_TIMEOUT_MS = 30_000;
|
||||
export const CROSS_OS_GATEWAY_STATUS_COMMAND_TIMEOUT_MS =
|
||||
@@ -204,11 +208,14 @@ export function parseArgs(argv) {
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parsePositiveIntegerEnv(name, fallback) {
|
||||
const raw = process.env[name]?.trim();
|
||||
export function parsePositiveIntegerEnv(name, fallback, env = process.env) {
|
||||
const raw = env[name]?.trim();
|
||||
if (!raw) {
|
||||
return fallback;
|
||||
}
|
||||
if (!/^\d+$/u.test(raw)) {
|
||||
throw new Error(`${name} must be a positive integer. Got: ${JSON.stringify(raw)}`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value) || value <= 0) {
|
||||
throw new Error(`${name} must be a positive integer. Got: ${JSON.stringify(raw)}`);
|
||||
@@ -2558,15 +2565,18 @@ export async function readBoundedCrossOsResponseText(
|
||||
async function waitForDiscordMessage(params) {
|
||||
const deadline = Date.now() + 3 * 60 * 1000;
|
||||
while (Date.now() < deadline) {
|
||||
const response = await fetch(
|
||||
`https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bot ${params.token}`,
|
||||
},
|
||||
},
|
||||
);
|
||||
const text = await readBoundedCrossOsResponseText(response);
|
||||
let response;
|
||||
let text;
|
||||
try {
|
||||
response = await fetch(
|
||||
`https://discord.com/api/v10/channels/${params.channelId}/messages?limit=20`,
|
||||
buildDiscordFetchInit(params.token),
|
||||
);
|
||||
text = await readBoundedCrossOsResponseText(response);
|
||||
} catch {
|
||||
await sleep(2_000);
|
||||
continue;
|
||||
}
|
||||
if (!response.ok) {
|
||||
await sleep(2_000);
|
||||
continue;
|
||||
@@ -2579,20 +2589,30 @@ async function waitForDiscordMessage(params) {
|
||||
throw new Error(`Discord host-side visibility check timed out for ${params.needle}.`);
|
||||
}
|
||||
|
||||
export function buildDiscordFetchInit(token, init = {}) {
|
||||
return {
|
||||
...init,
|
||||
signal: init.signal ?? AbortSignal.timeout(CROSS_OS_DISCORD_FETCH_TIMEOUT_MS),
|
||||
headers: {
|
||||
...init.headers,
|
||||
Authorization: `Bot ${token}`,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function postDiscordMessage(params) {
|
||||
const response = await fetch(
|
||||
`https://discord.com/api/v10/channels/${params.channelId}/messages`,
|
||||
{
|
||||
buildDiscordFetchInit(params.token, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bot ${params.token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
content: params.content,
|
||||
flags: 4096,
|
||||
}),
|
||||
},
|
||||
}),
|
||||
);
|
||||
const text = await readBoundedCrossOsResponseText(response);
|
||||
if (!response.ok) {
|
||||
@@ -2611,12 +2631,9 @@ async function deleteDiscordMessage(params) {
|
||||
}
|
||||
await fetch(
|
||||
`https://discord.com/api/v10/channels/${params.channelId}/messages/${params.messageId}`,
|
||||
{
|
||||
buildDiscordFetchInit(params.token, {
|
||||
method: "DELETE",
|
||||
headers: {
|
||||
Authorization: `Bot ${params.token}`,
|
||||
},
|
||||
},
|
||||
}),
|
||||
).catch(() => undefined);
|
||||
}
|
||||
|
||||
|
||||
@@ -303,11 +303,17 @@ export function utcCalendarDayDistance(left: Date, right: Date): number {
|
||||
|
||||
function positiveEnvInt(name: string, env: NodeJS.ProcessEnv, fallback: number): number {
|
||||
const raw = env[name]?.trim();
|
||||
if (raw === undefined || raw === "" || !/^[0-9]+$/u.test(raw)) {
|
||||
if (raw === undefined || raw === "") {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
if (!/^[1-9]\d*$/u.test(raw)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolveNpmReleaseCheckCommandTimeoutMs(
|
||||
|
||||
@@ -95,11 +95,17 @@ function ensurePreparedArtifacts(): void {
|
||||
|
||||
function positiveEnvInt(name: string, env: NodeJS.ProcessEnv, fallback: number): number {
|
||||
const raw = env[name]?.trim();
|
||||
if (raw === undefined || raw === "" || !/^[0-9]+$/u.test(raw)) {
|
||||
if (raw === undefined || raw === "") {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
if (!/^[1-9]\d*$/u.test(raw)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function resolvePrepackCommandTimeoutMs(env: NodeJS.ProcessEnv = process.env): number {
|
||||
|
||||
@@ -729,6 +729,7 @@ async function withBulkAdvisoryTimeout({ label, timeoutMs, run }) {
|
||||
async function readBoundedResponseText(response, maxBytes, label) {
|
||||
const contentLength = Number.parseInt(response.headers?.get?.("content-length") ?? "", 10);
|
||||
if (Number.isFinite(contentLength) && contentLength > maxBytes) {
|
||||
await response.body?.cancel().catch(() => undefined);
|
||||
throw Object.assign(new Error(`${label} exceeded ${maxBytes} bytes`), { code: "ETOOBIG" });
|
||||
}
|
||||
|
||||
|
||||
@@ -116,23 +116,36 @@ const CAPTURE_MAX_BUFFER_BYTES = 32 * 1024 * 1024;
|
||||
const DEFAULT_COMMAND_TIMEOUT_MS = readPositiveInt(
|
||||
process.env.OPENCLAW_RELEASE_BETA_SMOKE_COMMAND_MS,
|
||||
10 * 60_000,
|
||||
"OPENCLAW_RELEASE_BETA_SMOKE_COMMAND_MS",
|
||||
);
|
||||
const TELEGRAM_POLL_INTERVAL_MS = readPositiveInt(
|
||||
process.env.OPENCLAW_RELEASE_BETA_SMOKE_POLL_INTERVAL_MS,
|
||||
30_000,
|
||||
"OPENCLAW_RELEASE_BETA_SMOKE_POLL_INTERVAL_MS",
|
||||
);
|
||||
const TELEGRAM_POLL_TIMEOUT_MS = readPositiveInt(
|
||||
process.env.OPENCLAW_RELEASE_BETA_SMOKE_POLL_TIMEOUT_MS,
|
||||
4 * 60 * 60_000,
|
||||
"OPENCLAW_RELEASE_BETA_SMOKE_POLL_TIMEOUT_MS",
|
||||
);
|
||||
|
||||
function readPositiveInt(raw: string | undefined, fallback: number): number {
|
||||
export function readPositiveInt(
|
||||
raw: string | undefined,
|
||||
fallback: number,
|
||||
label = "value",
|
||||
): number {
|
||||
const text = (raw ?? "").trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
if (!text) {
|
||||
return fallback;
|
||||
}
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(raw)}`);
|
||||
}
|
||||
const parsed = Number(text);
|
||||
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${label} must be a positive integer. Got: ${JSON.stringify(raw)}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
export function run(command: string, args: string[], input?: RunOptions): string {
|
||||
|
||||
@@ -12,6 +12,7 @@ const DEFAULT_RELEASE_PROFILE = "beta";
|
||||
const DEFAULT_NPM_DIST_TAG = "beta";
|
||||
const DEFAULT_PLUGIN_SCOPE = "all-publishable";
|
||||
const DEFAULT_TELEGRAM_PROVIDER_MODE = "mock-openai";
|
||||
const DEFAULT_GITHUB_API_TIMEOUT_MS = 30_000;
|
||||
|
||||
function usage() {
|
||||
return `Usage: pnpm release:candidate -- --tag vYYYY.M.D-beta.N [options]
|
||||
@@ -182,15 +183,43 @@ function readJson(path, label) {
|
||||
}
|
||||
}
|
||||
|
||||
async function githubApi(path) {
|
||||
const token = run("gh", ["auth", "token"], { capture: true }).trim();
|
||||
const response = await fetch(`https://api.github.com/${path}`, {
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
function githubApiTimeoutMs() {
|
||||
const raw = process.env.OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS;
|
||||
if (!raw) {
|
||||
return DEFAULT_GITHUB_API_TIMEOUT_MS;
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
throw new Error("OPENCLAW_RELEASE_CANDIDATE_GITHUB_API_TIMEOUT_MS must be a positive number");
|
||||
}
|
||||
return Math.trunc(value);
|
||||
}
|
||||
|
||||
function githubApiTimedOut(error) {
|
||||
return (
|
||||
error instanceof DOMException && (error.name === "AbortError" || error.name === "TimeoutError")
|
||||
);
|
||||
}
|
||||
|
||||
export async function githubApi(path, options = {}) {
|
||||
const token = options.token ?? run("gh", ["auth", "token"], { capture: true }).trim();
|
||||
const timeoutMs = options.timeoutMs ?? githubApiTimeoutMs();
|
||||
let response;
|
||||
try {
|
||||
response = await (options.fetchImpl ?? fetch)(`https://api.github.com/${path}`, {
|
||||
signal: AbortSignal.timeout(timeoutMs),
|
||||
headers: {
|
||||
Accept: "application/vnd.github+json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (githubApiTimedOut(error)) {
|
||||
throw new Error(`GitHub API ${path} timed out after ${timeoutMs}ms`, { cause: error });
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (!response.ok) {
|
||||
throw new Error(`GitHub API ${path} failed with ${response.status}: ${await response.text()}`);
|
||||
}
|
||||
|
||||
@@ -177,11 +177,17 @@ const PACKED_PLUGIN_SDK_TYPESCRIPT_SMOKE_FIXTURE = resolve(
|
||||
|
||||
function positiveEnvInt(name: string, fallback: number): number {
|
||||
const raw = process.env[name]?.trim();
|
||||
if (raw === undefined || raw === "" || !/^[0-9]+$/u.test(raw)) {
|
||||
if (raw === undefined || raw === "") {
|
||||
return fallback;
|
||||
}
|
||||
const value = Number.parseInt(raw, 10);
|
||||
return Number.isSafeInteger(value) && value > 0 ? value : fallback;
|
||||
if (!/^[1-9]\d*$/u.test(raw)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
const value = Number(raw);
|
||||
if (!Number.isSafeInteger(value)) {
|
||||
throw new Error(`invalid ${name}: ${raw}`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function runReleaseCheckCommand(
|
||||
|
||||
@@ -185,14 +185,11 @@ function run(command, args, options = {}) {
|
||||
};
|
||||
const terminateChild = () => {
|
||||
killChild("SIGTERM");
|
||||
killTimer = setTimeout(
|
||||
() => {
|
||||
killTimer = undefined;
|
||||
killChild("SIGKILL");
|
||||
timeoutReject?.();
|
||||
},
|
||||
options.killAfterMs ?? COMMAND_TIMEOUT_KILL_AFTER_MS,
|
||||
);
|
||||
killTimer = setTimeout(() => {
|
||||
killTimer = undefined;
|
||||
killChild("SIGKILL");
|
||||
timeoutReject?.();
|
||||
}, options.killAfterMs ?? COMMAND_TIMEOUT_KILL_AFTER_MS);
|
||||
};
|
||||
const timeout =
|
||||
options.timeoutMs === undefined
|
||||
@@ -442,6 +439,25 @@ async function preparePackageSourceWorktree(ref) {
|
||||
return { selectedSha, sourceDir, trustedReason };
|
||||
}
|
||||
|
||||
async function cleanupPackageSourceWorktree(
|
||||
sourceDir,
|
||||
{ resolveError, runImpl = run, consoleError = console.error } = {},
|
||||
) {
|
||||
try {
|
||||
await runImpl("git", ["worktree", "remove", "--force", sourceDir]);
|
||||
} catch (cleanupError) {
|
||||
if (!resolveError) {
|
||||
throw cleanupError;
|
||||
}
|
||||
const message = cleanupError instanceof Error ? cleanupError.message : String(cleanupError);
|
||||
consoleError(
|
||||
`warning: failed to remove temporary package source worktree ${sourceDir}: ${message}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export const cleanupPackageSourceWorktreeForTest = cleanupPackageSourceWorktree;
|
||||
|
||||
async function installPackageSourceDeps(sourceDir) {
|
||||
await run(
|
||||
"pnpm",
|
||||
@@ -1122,6 +1138,7 @@ async function resolveCandidate(options) {
|
||||
let packageTrustedSourceId = "";
|
||||
let packageWorktreeDir = "";
|
||||
let artifactMetadata = {};
|
||||
let resolveError;
|
||||
|
||||
try {
|
||||
if (options.source === "ref") {
|
||||
@@ -1198,9 +1215,12 @@ async function resolveCandidate(options) {
|
||||
`source must be one of: ref, npm, url, trusted-url, artifact. Got: ${options.source}`,
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
resolveError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
if (packageWorktreeDir) {
|
||||
await run("git", ["worktree", "remove", "--force", packageWorktreeDir]).catch(() => {});
|
||||
await cleanupPackageSourceWorktree(packageWorktreeDir, { resolveError });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -66,19 +66,80 @@ function main(argv = process.argv.slice(2)) {
|
||||
}
|
||||
|
||||
const spawnCommand = resolveSpawnCommand(parsed.command, parsed.args);
|
||||
const useChildProcessGroup = process.platform !== "win32" && !process.stdin.isTTY;
|
||||
const child = spawn(spawnCommand.command, spawnCommand.args, {
|
||||
detached: useChildProcessGroup,
|
||||
env: {
|
||||
...process.env,
|
||||
...parsed.env,
|
||||
},
|
||||
stdio: "inherit",
|
||||
});
|
||||
const forceKillDelayMs = Math.max(
|
||||
1,
|
||||
Number.parseInt(process.env.OPENCLAW_RUN_WITH_ENV_FORCE_KILL_MS ?? "5000", 10) || 5_000,
|
||||
);
|
||||
let forwardedSignal = null;
|
||||
let forceKillTimer = null;
|
||||
// Keep the child in the foreground process group so TTY signals such as
|
||||
// Ctrl-C, Ctrl-Z, and window resizes stay native. Forward direct wrapper
|
||||
// shutdown signals that would otherwise only kill this small parent process.
|
||||
const forwardedSignals = ["SIGTERM", "SIGHUP"];
|
||||
const forwardedSignals = useChildProcessGroup
|
||||
? ["SIGTERM", "SIGHUP", "SIGINT"]
|
||||
: ["SIGTERM", "SIGHUP"];
|
||||
const signalChild = (signal) => {
|
||||
if (useChildProcessGroup && typeof child.pid === "number") {
|
||||
try {
|
||||
process.kill(-child.pid, signal);
|
||||
return;
|
||||
} catch (error) {
|
||||
if (error?.code !== "ESRCH") {
|
||||
child.kill(signal);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
child.kill(signal);
|
||||
};
|
||||
const childProcessGroupAlive = () => {
|
||||
if (!useChildProcessGroup || typeof child.pid !== "number") {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
process.kill(-child.pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const exitWithForwardedSignal = () => {
|
||||
if (!forwardedSignal) {
|
||||
return;
|
||||
}
|
||||
const finish = () => {
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
}
|
||||
process.kill(process.pid, forwardedSignal);
|
||||
};
|
||||
if (!childProcessGroupAlive()) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
const deadline = Date.now() + forceKillDelayMs;
|
||||
const drainTimer = setInterval(() => {
|
||||
if (!childProcessGroupAlive()) {
|
||||
clearInterval(drainTimer);
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
if (Date.now() >= deadline) {
|
||||
clearInterval(drainTimer);
|
||||
signalChild("SIGKILL");
|
||||
finish();
|
||||
}
|
||||
}, 50);
|
||||
};
|
||||
|
||||
const cleanupSignalHandlers = () => {
|
||||
for (const signal of forwardedSignals) {
|
||||
@@ -90,8 +151,8 @@ function main(argv = process.argv.slice(2)) {
|
||||
signal,
|
||||
() => {
|
||||
forwardedSignal ??= signal;
|
||||
child.kill(signal);
|
||||
forceKillTimer ??= setTimeout(() => child.kill("SIGKILL"), 5_000);
|
||||
signalChild(signal);
|
||||
forceKillTimer ??= setTimeout(() => signalChild("SIGKILL"), forceKillDelayMs);
|
||||
},
|
||||
]),
|
||||
);
|
||||
@@ -101,13 +162,13 @@ function main(argv = process.argv.slice(2)) {
|
||||
|
||||
child.on("exit", (code, signal) => {
|
||||
cleanupSignalHandlers();
|
||||
if (forwardedSignal) {
|
||||
exitWithForwardedSignal();
|
||||
return;
|
||||
}
|
||||
if (forceKillTimer) {
|
||||
clearTimeout(forceKillTimer);
|
||||
}
|
||||
if (forwardedSignal) {
|
||||
process.kill(process.pid, forwardedSignal);
|
||||
return;
|
||||
}
|
||||
if (signal) {
|
||||
process.kill(process.pid, signal);
|
||||
return;
|
||||
|
||||
@@ -394,6 +394,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
".github/workflows/ci-check-testbox.yml",
|
||||
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
|
||||
],
|
||||
[
|
||||
".github/workflows/ci-check-arm-testbox.yml",
|
||||
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
|
||||
],
|
||||
[
|
||||
".github/workflows/crabbox-hydrate.yml",
|
||||
["test/scripts/ci-workflow-guards.test.ts", "test/scripts/package-acceptance-workflow.test.ts"],
|
||||
@@ -604,6 +608,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
|
||||
["test/scripts/docker-build-helper.test.ts", "test/scripts/openclaw-test-state.test.ts"],
|
||||
],
|
||||
["scripts/e2e/plugin-lifecycle-matrix-docker.sh", ["test/scripts/docker-build-helper.test.ts"]],
|
||||
[
|
||||
"scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs",
|
||||
["test/scripts/plugin-lifecycle-measure.test.ts"],
|
||||
],
|
||||
[
|
||||
"scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs",
|
||||
["test/scripts/plugin-lifecycle-probe.test.ts"],
|
||||
@@ -1639,8 +1647,10 @@ function resolveToolingChangedTestTargets(changedPaths, cwd = process.cwd()) {
|
||||
return [...new Set(targets)];
|
||||
}
|
||||
|
||||
const TOOLING_SCRIPT_PATH_PATTERN = /^scripts\/(.+)\.(?:mjs|cjs|js|mts|cts|ts|sh|py|ps1)$/u;
|
||||
|
||||
function resolveConventionalToolingTestTargets(changedPath, cwd = process.cwd()) {
|
||||
const match = /^scripts\/(.+)\.(?:mjs|ts|js|sh|py)$/u.exec(changedPath);
|
||||
const match = TOOLING_SCRIPT_PATH_PATTERN.exec(changedPath);
|
||||
if (!match) {
|
||||
return null;
|
||||
}
|
||||
@@ -1659,14 +1669,45 @@ function resolveConventionalToolingTestTargets(changedPath, cwd = process.cwd())
|
||||
return targets.length > 0 ? targets : null;
|
||||
}
|
||||
|
||||
function isToolingScriptPath(changedPath) {
|
||||
return TOOLING_SCRIPT_PATH_PATTERN.test(changedPath);
|
||||
}
|
||||
|
||||
function resolveParallelsToolingTestTargets(changedPath) {
|
||||
if (!/^scripts\/e2e\/parallels\/[^/]+\.ts$/u.test(changedPath)) {
|
||||
return null;
|
||||
}
|
||||
const targets = ["test/scripts/parallels-smoke-model.test.ts"];
|
||||
if (
|
||||
[
|
||||
"scripts/e2e/parallels/guest-transports.ts",
|
||||
"scripts/e2e/parallels/host-command.ts",
|
||||
"scripts/e2e/parallels/npm-update-scripts.ts",
|
||||
"scripts/e2e/parallels/npm-update-smoke.ts",
|
||||
].includes(changedPath)
|
||||
) {
|
||||
targets.push("test/scripts/parallels-npm-update-smoke.test.ts");
|
||||
}
|
||||
if (changedPath === "scripts/e2e/parallels/update-job-timeout.ts") {
|
||||
targets.push("test/scripts/parallels-update-job-timeout.test.ts");
|
||||
}
|
||||
return targets;
|
||||
}
|
||||
|
||||
function resolveToolingTestTargets(changedPath, cwd = process.cwd()) {
|
||||
const explicitTargets =
|
||||
TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ?? TOOLING_TEST_TARGETS.get(changedPath);
|
||||
TOOLING_SOURCE_TEST_TARGETS.get(changedPath) ??
|
||||
TOOLING_TEST_TARGETS.get(changedPath) ??
|
||||
resolveParallelsToolingTestTargets(changedPath);
|
||||
const conventionalTargets = resolveConventionalToolingTestTargets(changedPath, cwd);
|
||||
if (explicitTargets && conventionalTargets) {
|
||||
return uniqueOrdered([...explicitTargets, ...conventionalTargets]);
|
||||
}
|
||||
return explicitTargets ?? conventionalTargets;
|
||||
return (
|
||||
explicitTargets ??
|
||||
conventionalTargets ??
|
||||
(isToolingScriptPath(changedPath) ? [TOOLING_VITEST_CONFIG] : null)
|
||||
);
|
||||
}
|
||||
|
||||
function shouldUseBroadChangedTargets(env = process.env) {
|
||||
|
||||
+24
-12
@@ -294,26 +294,34 @@ function findFatalUnresolvedImport(lines) {
|
||||
return null;
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value) {
|
||||
function parsePositiveIntegerEnv(value, name) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return null;
|
||||
const text = value.trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`${name} must be a positive integer`);
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
const parsed = Number(text);
|
||||
if (!Number.isSafeInteger(parsed) || parsed <= 0) {
|
||||
throw new Error(`${name} must be a positive safe integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseNonNegativeInteger(value) {
|
||||
function parseNonNegativeIntegerEnv(value, name) {
|
||||
if (typeof value !== "string" || value.trim() === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 0) {
|
||||
return null;
|
||||
const text = value.trim();
|
||||
if (!/^\d+$/u.test(text)) {
|
||||
throw new Error(`${name} must be a non-negative integer`);
|
||||
}
|
||||
return Math.trunc(parsed);
|
||||
const parsed = Number(text);
|
||||
if (!Number.isSafeInteger(parsed)) {
|
||||
throw new Error(`${name} must be a non-negative safe integer`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseCgroupMemoryLimitBytes(value) {
|
||||
@@ -582,9 +590,13 @@ export async function runTsdownBuildInvocation(invocation, params = {}) {
|
||||
const stderr = params.stderr ?? process.stderr;
|
||||
const env = params.env ?? process.env;
|
||||
const scanner = params.scanner ?? createTsdownOutputScanner();
|
||||
const timeoutMs = parsePositiveInteger(env.OPENCLAW_TSDOWN_TIMEOUT_MS);
|
||||
const timeoutMs = parsePositiveIntegerEnv(
|
||||
env.OPENCLAW_TSDOWN_TIMEOUT_MS,
|
||||
"OPENCLAW_TSDOWN_TIMEOUT_MS",
|
||||
);
|
||||
const heartbeatMs =
|
||||
parseNonNegativeInteger(env.OPENCLAW_TSDOWN_HEARTBEAT_MS) ?? DEFAULT_HEARTBEAT_MS;
|
||||
parseNonNegativeIntegerEnv(env.OPENCLAW_TSDOWN_HEARTBEAT_MS, "OPENCLAW_TSDOWN_HEARTBEAT_MS") ??
|
||||
DEFAULT_HEARTBEAT_MS;
|
||||
let timedOut = false;
|
||||
let settled = false;
|
||||
let lastOutputAt = Date.now();
|
||||
|
||||
@@ -304,6 +304,39 @@ describe("sanitizeUserFacingText", () => {
|
||||
expect(sanitizeUserFacingText(input)).toBe("Before\n\nAfter");
|
||||
});
|
||||
|
||||
it("strips internal tool trace warning lines from error-context delivery text", () => {
|
||||
const input = [
|
||||
"Visible intro.",
|
||||
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
"⚠️ 🛠️ gh search issues --repo openclaw/openclaw --state open --no-search-pages.jsonl /tmp/openclaw_open_unlabeled_current.json (agent) failed",
|
||||
"⚠️ 🛠️ gh search issues --repo openclaw/openclaw --state open (agent) failed: command timed out",
|
||||
"🛠️ run git status",
|
||||
"📖 Read: lines 1-40 from secret.md",
|
||||
"Visible outro.",
|
||||
].join("\n");
|
||||
|
||||
expect(sanitizeUserFacingText(input, { errorContext: true })).toBe(
|
||||
"Visible intro.\nVisible outro.",
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves explicit tool progress outside error-context delivery text", () => {
|
||||
const input = "🛠️ Exec: echo queued-progress";
|
||||
|
||||
expect(sanitizeUserFacingText(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("preserves internal trace examples inside fenced code", () => {
|
||||
const input = [
|
||||
"Example:",
|
||||
"```",
|
||||
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
"```",
|
||||
].join("\n");
|
||||
|
||||
expect(sanitizeUserFacingText(input)).toBe(input);
|
||||
});
|
||||
|
||||
it("strips plural XML function-call wrappers before user-facing delivery", () => {
|
||||
const input = [
|
||||
"Before",
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "../../../packages/normalization-core/src/string-coerce.js";
|
||||
import { stripPlainTextToolCallBlocks } from "../../../packages/tool-call-repair/src/index.js";
|
||||
import { stripInboundMetadata } from "../../auto-reply/reply/strip-inbound-meta.js";
|
||||
import {
|
||||
@@ -11,10 +15,7 @@ import {
|
||||
} from "../../shared/assistant-error-format.js";
|
||||
import { coerceChatContentText } from "../../shared/chat-content.js";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "../../../packages/normalization-core/src/string-coerce.js";
|
||||
import {
|
||||
stripAssistantInternalTraceLines,
|
||||
stripLegacyBracketToolCallBlocks,
|
||||
stripMinimaxToolCallXml,
|
||||
stripToolCallXmlTags,
|
||||
@@ -414,8 +415,11 @@ export function sanitizeUserFacingText(text: unknown, opts?: { errorContext?: bo
|
||||
// It is internal scaffolding, so drop standalone placeholder lines before delivery
|
||||
// while preserving ordinary inline mentions a user may be discussing.
|
||||
const withoutPlaceholder = stripToolCallsOmittedPlaceholderLines(withoutToolCallXml);
|
||||
const withoutInternalTraceLines = errorContext
|
||||
? stripAssistantInternalTraceLines(withoutPlaceholder)
|
||||
: withoutPlaceholder;
|
||||
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(
|
||||
stripLegacyBracketToolCallBlocks(withoutPlaceholder),
|
||||
stripLegacyBracketToolCallBlocks(withoutInternalTraceLines),
|
||||
);
|
||||
const trimmed = withoutToolCallBlocks.trim();
|
||||
if (!trimmed) {
|
||||
|
||||
@@ -450,6 +450,67 @@ describe("exec shell snapshots", () => {
|
||||
await expect(runAlias()).resolves.toBe("new");
|
||||
});
|
||||
|
||||
it("refreshes fresh cached snapshots that no longer parse", async () => {
|
||||
const bash = resolveBashForTest();
|
||||
if (!bash) {
|
||||
return;
|
||||
}
|
||||
|
||||
const home = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-corrupt-home-"));
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-corrupt-state-"));
|
||||
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-snapshot-corrupt-cwd-"));
|
||||
tempDirs.push(home, stateDir, cwd);
|
||||
setSnapshotStateForTest(stateDir, { home });
|
||||
fs.writeFileSync(path.join(home, ".bashrc"), "alias oc_clean_alias='printf ok'\n");
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HOME: home,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
};
|
||||
const shellArgs = getPosixShellArgs(bash);
|
||||
const wrap = async (): Promise<string> =>
|
||||
await maybeWrapCommandWithShellSnapshot({
|
||||
command: "oc_clean_alias",
|
||||
shell: bash,
|
||||
shellArgs,
|
||||
cwd,
|
||||
env,
|
||||
});
|
||||
|
||||
const firstWrapped = await wrap();
|
||||
expect(firstWrapped).not.toBe("oc_clean_alias");
|
||||
const snapshotDir = resolveShellSnapshotDir(env);
|
||||
const snapshotFiles = fs.readdirSync(snapshotDir).filter((entry) => entry.endsWith(".sh"));
|
||||
expect(snapshotFiles).toHaveLength(1);
|
||||
const snapshotPath = path.join(snapshotDir, snapshotFiles[0]);
|
||||
fs.writeFileSync(
|
||||
snapshotPath,
|
||||
[
|
||||
"# OpenClaw exec shell snapshot. Generated; do not edit.",
|
||||
"unalias -a 2>/dev/null || true",
|
||||
"oc_broken_fn() {",
|
||||
" --!(no-*)dir*",
|
||||
"}",
|
||||
"",
|
||||
].join("\n"),
|
||||
);
|
||||
resetShellSnapshotCacheForTests();
|
||||
|
||||
const wrapped = await wrap();
|
||||
const result = spawnSync(bash, [...shellArgs, wrapped], {
|
||||
cwd,
|
||||
env,
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toBe("ok");
|
||||
expect(result.stderr).toBe("");
|
||||
expect(fs.readFileSync(snapshotPath, "utf8")).not.toContain("--!(no-*)dir*");
|
||||
});
|
||||
|
||||
it("refuses to persist aliases or functions with literal secret-looking values", async () => {
|
||||
const bash = resolveBashForTest();
|
||||
if (!bash) {
|
||||
|
||||
@@ -241,7 +241,7 @@ async function validateSnapshot(
|
||||
shellArgs: opts.shellArgs,
|
||||
cwd: opts.cwd,
|
||||
env: buildTrustedSnapshotCaptureEnv(opts.env),
|
||||
command: `. ${shQuote(snapshotPath)} >/dev/null 2>&1; :`,
|
||||
command: `. ${shQuote(snapshotPath)} >/dev/null 2>&1`,
|
||||
timeoutMs: 2_000,
|
||||
});
|
||||
return result.status === 0;
|
||||
|
||||
@@ -49,6 +49,8 @@ export type PartialReplyPayload = Pick<ReplyPayload, "text" | "mediaUrls"> & {
|
||||
export type GetReplyOptions = {
|
||||
/** Override run id for agent events (defaults to random UUID). */
|
||||
runId?: string;
|
||||
/** Stable provider prompt-cache affinity key; distinct from run id/idempotency. */
|
||||
promptCacheKey?: string;
|
||||
/** Abort signal for the underlying agent run. */
|
||||
abortSignal?: AbortSignal;
|
||||
/** Optional inbound images (used for webchat attachments). */
|
||||
|
||||
@@ -435,6 +435,28 @@ function createMinimalRunAgentTurnParams(overrides?: {
|
||||
};
|
||||
}
|
||||
|
||||
const NON_DIRECT_FAILURE_SURFACE_CASES = [
|
||||
{ label: "Discord group", provider: "discord", chatType: "group" },
|
||||
{ label: "Discord channel", provider: "discord", chatType: "channel" },
|
||||
{ label: "Slack channel", provider: "slack", chatType: "channel" },
|
||||
{ label: "Telegram group", provider: "telegram", chatType: "group" },
|
||||
{ label: "WhatsApp group", provider: "whatsapp", chatType: "group" },
|
||||
{ label: "Microsoft Teams channel", provider: "msteams", chatType: "channel" },
|
||||
] as const;
|
||||
|
||||
function createNonDirectFailureSessionCtx(
|
||||
testCase: (typeof NON_DIRECT_FAILURE_SURFACE_CASES)[number],
|
||||
): TemplateContext {
|
||||
return {
|
||||
Provider: testCase.provider,
|
||||
Surface: testCase.provider,
|
||||
ChatType: testCase.chatType,
|
||||
GroupSubject: `${testCase.label} fixture`,
|
||||
GroupChannel: "#general",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext;
|
||||
}
|
||||
|
||||
describe("computeContextAwareReserveTokensFloor", () => {
|
||||
it("returns 100000 for 1M context windows", () => {
|
||||
expect(computeContextAwareReserveTokensFloor(1_000_000)).toBe(100_000);
|
||||
@@ -2928,51 +2950,37 @@ describe("runAgentTurnWithFallback", () => {
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("surfaces model capacity errors from no-text mid-turn failures", async () => {
|
||||
state.runEmbeddedAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "thinking", isReasoning: true }],
|
||||
meta: {
|
||||
error: {
|
||||
kind: "server_overloaded",
|
||||
message: "Selected model is at capacity. Please try a different model.",
|
||||
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
|
||||
"surfaces model capacity errors from no-text mid-turn failures in $label chats",
|
||||
async (testCase) => {
|
||||
state.runEmbeddedAgentMock.mockResolvedValueOnce({
|
||||
payloads: [{ text: "thinking", isReasoning: true }],
|
||||
meta: {
|
||||
error: {
|
||||
kind: "server_overloaded",
|
||||
message: "Selected model is at capacity. Please try a different model.",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback({
|
||||
commandBody: "hello",
|
||||
followupRun: createFollowupRun(),
|
||||
sessionCtx: {
|
||||
Provider: "whatsapp",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext,
|
||||
opts: {},
|
||||
typingSignals: createMockTypingSignaler(),
|
||||
blockReplyPipeline: null,
|
||||
blockStreamingEnabled: false,
|
||||
resolvedBlockStreamingBreak: "message_end",
|
||||
applyReplyToMode: (payload) => payload,
|
||||
shouldEmitToolResult: () => true,
|
||||
shouldEmitToolOutput: () => false,
|
||||
pendingToolTasks: new Set(),
|
||||
resetSessionAfterRoleOrderingConflict: async () => false,
|
||||
isHeartbeat: false,
|
||||
sessionKey: "main",
|
||||
getActiveSessionEntry: () => undefined,
|
||||
resolvedVerboseLevel: "off",
|
||||
});
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback(
|
||||
createMinimalRunAgentTurnParams({
|
||||
sessionCtx: createNonDirectFailureSessionCtx(testCase),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.kind).toBe("success");
|
||||
if (result.kind === "success") {
|
||||
expect(result.runResult.payloads).toEqual([
|
||||
{
|
||||
text: "⚠️ Selected model is at capacity. Try a different model, or wait and retry.",
|
||||
isError: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
});
|
||||
expect(result.kind).toBe("success");
|
||||
if (result.kind === "success") {
|
||||
expect(result.runResult.payloads).toEqual([
|
||||
{
|
||||
text: "⚠️ Selected model is at capacity. Try a different model, or wait and retry.",
|
||||
isError: true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("surfaces model capacity errors from pre-reply CLI failures", async () => {
|
||||
state.runWithModelFallbackMock.mockRejectedValueOnce(
|
||||
@@ -3010,6 +3018,7 @@ describe("runAgentTurnWithFallback", () => {
|
||||
expect(result).toEqual({
|
||||
kind: "final",
|
||||
payload: {
|
||||
isError: true,
|
||||
text: "⚠️ Selected model is at capacity. Try a different model, or wait and retry.",
|
||||
},
|
||||
});
|
||||
@@ -5118,9 +5127,9 @@ describe("runAgentTurnWithFallback", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["group", "channel"] as const)(
|
||||
"keeps raw runner failure boilerplate out of Discord %s chats",
|
||||
async (chatType) => {
|
||||
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
|
||||
"keeps raw runner failure boilerplate out of $label chats",
|
||||
async (testCase) => {
|
||||
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
||||
new Error("openai/gpt-5.5 ended with an incomplete terminal response"),
|
||||
);
|
||||
@@ -5128,14 +5137,7 @@ describe("runAgentTurnWithFallback", () => {
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback(
|
||||
createMinimalRunAgentTurnParams({
|
||||
sessionCtx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: chatType,
|
||||
GroupSubject: "agent group",
|
||||
GroupChannel: "#general",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext,
|
||||
sessionCtx: createNonDirectFailureSessionCtx(testCase),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5225,12 +5227,9 @@ describe("runAgentTurnWithFallback", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it.each(["group", "channel"] as const)(
|
||||
"keeps default silent behavior in Discord %s chats when silentReply policy is unset",
|
||||
async (chatType) => {
|
||||
// Sanity check: explicit `{}` config (no silentReply) must still resolve
|
||||
// to the documented default `group: "allow"` and produce a silent payload
|
||||
// — the new policy hookup must not regress the default behavior.
|
||||
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
|
||||
"keeps default silent behavior in $label chats when silentReply policy is unset",
|
||||
async (testCase) => {
|
||||
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
||||
new Error("openai/gpt-5.5 ended with an incomplete terminal response"),
|
||||
);
|
||||
@@ -5242,14 +5241,7 @@ describe("runAgentTurnWithFallback", () => {
|
||||
const result = await runAgentTurnWithFallback(
|
||||
createMinimalRunAgentTurnParams({
|
||||
followupRun,
|
||||
sessionCtx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: chatType,
|
||||
GroupSubject: "agent group",
|
||||
GroupChannel: "#general",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext,
|
||||
sessionCtx: createNonDirectFailureSessionCtx(testCase),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5260,9 +5252,9 @@ describe("runAgentTurnWithFallback", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["group", "channel"] as const)(
|
||||
"keeps classified non-transient failures visible in Discord %s chats",
|
||||
async (chatType) => {
|
||||
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
|
||||
"keeps classified non-transient failures visible in $label chats",
|
||||
async (testCase) => {
|
||||
state.runEmbeddedAgentMock.mockRejectedValueOnce(
|
||||
new Error('No API key found for provider "openai"'),
|
||||
);
|
||||
@@ -5270,14 +5262,7 @@ describe("runAgentTurnWithFallback", () => {
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback(
|
||||
createMinimalRunAgentTurnParams({
|
||||
sessionCtx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: chatType,
|
||||
GroupSubject: "agent group",
|
||||
GroupChannel: "#general",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext,
|
||||
sessionCtx: createNonDirectFailureSessionCtx(testCase),
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -5289,28 +5274,44 @@ describe("runAgentTurnWithFallback", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["group", "channel"] as const)(
|
||||
"keeps rate-limit fallback copy out of Discord %s chats",
|
||||
async (chatType) => {
|
||||
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
|
||||
"surfaces rate-limit fallback copy in $label chats",
|
||||
async (testCase) => {
|
||||
state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("429 rate limit exceeded"));
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback(
|
||||
createMinimalRunAgentTurnParams({
|
||||
sessionCtx: {
|
||||
Provider: "discord",
|
||||
Surface: "discord",
|
||||
ChatType: chatType,
|
||||
GroupSubject: "agent group",
|
||||
GroupChannel: "#general",
|
||||
MessageSid: "msg",
|
||||
} as unknown as TemplateContext,
|
||||
sessionCtx: createNonDirectFailureSessionCtx(testCase),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.kind).toBe("final");
|
||||
if (result.kind === "final") {
|
||||
expect(result.payload.text).toBe(SILENT_REPLY_TOKEN);
|
||||
expect(result.payload.isError).toBe(true);
|
||||
expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN);
|
||||
expect(result.payload.text).toContain("rate-limited");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(NON_DIRECT_FAILURE_SURFACE_CASES)(
|
||||
"surfaces overloaded fallback copy in $label chats",
|
||||
async (testCase) => {
|
||||
state.runEmbeddedAgentMock.mockRejectedValueOnce(new Error("model is overloaded"));
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback(
|
||||
createMinimalRunAgentTurnParams({
|
||||
sessionCtx: createNonDirectFailureSessionCtx(testCase),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.kind).toBe("final");
|
||||
if (result.kind === "final") {
|
||||
expect(result.payload.isError).toBe(true);
|
||||
expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN);
|
||||
expect(result.payload.text).toContain("overloaded");
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -5344,6 +5345,7 @@ describe("runAgentTurnWithFallback", () => {
|
||||
|
||||
expect(result.kind).toBe("final");
|
||||
if (result.kind === "final") {
|
||||
expect(result.payload.isError).toBe(true);
|
||||
expect(result.payload.text).not.toBe(SILENT_REPLY_TOKEN);
|
||||
expect(result.payload.text).toContain("rate-limited");
|
||||
}
|
||||
|
||||
@@ -652,17 +652,12 @@ function resolveExternalRunFailureTextForConversation(params: {
|
||||
text: string;
|
||||
sessionCtx: TemplateContext;
|
||||
isGenericRunnerFailure: boolean;
|
||||
suppressInNonDirect?: boolean;
|
||||
cfg?: OpenClawConfig;
|
||||
}): string {
|
||||
if (!isNonDirectConversationContext(params.sessionCtx)) {
|
||||
return params.text;
|
||||
}
|
||||
if (
|
||||
!params.suppressInNonDirect &&
|
||||
!params.isGenericRunnerFailure &&
|
||||
!params.text.includes(AGENT_FAILED_BEFORE_REPLY_TEXT)
|
||||
) {
|
||||
if (!params.isGenericRunnerFailure && !params.text.includes(AGENT_FAILED_BEFORE_REPLY_TEXT)) {
|
||||
return params.text;
|
||||
}
|
||||
// Match normal reply routing: default group/channel failures stay silent,
|
||||
@@ -850,7 +845,11 @@ function buildExternalRunFailureReply(
|
||||
}
|
||||
|
||||
function markAgentRunFailureReplyPayload<T extends ReplyPayload>(payload: T): T {
|
||||
return markReplyPayloadForSourceSuppressionDelivery(payload);
|
||||
const marked = markReplyPayloadForSourceSuppressionDelivery(payload);
|
||||
if (!isSilentReplyText(marked.text, SILENT_REPLY_TOKEN)) {
|
||||
marked.isError = true;
|
||||
}
|
||||
return marked;
|
||||
}
|
||||
|
||||
export function buildKnownAgentRunFailureReplyPayload(params: {
|
||||
@@ -904,7 +903,6 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
|
||||
text: buildRateLimitCooldownMessage(params.err),
|
||||
sessionCtx: params.sessionCtx,
|
||||
isGenericRunnerFailure: false,
|
||||
suppressInNonDirect: true,
|
||||
cfg: params.cfg,
|
||||
}),
|
||||
});
|
||||
@@ -916,7 +914,6 @@ export function buildKnownAgentRunFailureReplyPayload(params: {
|
||||
text: rateLimitOrOverloadedCopy,
|
||||
sessionCtx: params.sessionCtx,
|
||||
isGenericRunnerFailure: false,
|
||||
suppressInNonDirect: true,
|
||||
cfg: params.cfg,
|
||||
}),
|
||||
});
|
||||
@@ -2231,6 +2228,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
hasRepliedRef: params.opts?.hasRepliedRef,
|
||||
provider,
|
||||
runId,
|
||||
promptCacheKey: params.opts?.promptCacheKey,
|
||||
allowTransientCooldownProbe: runOptions?.allowTransientCooldownProbe,
|
||||
model,
|
||||
});
|
||||
@@ -2924,7 +2922,6 @@ export async function runAgentTurnWithFallback(params: {
|
||||
text: fallbackText,
|
||||
sessionCtx: params.sessionCtx,
|
||||
isGenericRunnerFailure: externalRunFailureReply?.isGenericRunnerFailure ?? false,
|
||||
suppressInNonDirect: Boolean(isRateLimit || rateLimitOrOverloadedCopy),
|
||||
cfg: params.followupRun.run.config,
|
||||
});
|
||||
|
||||
@@ -2993,7 +2990,6 @@ export async function runAgentTurnWithFallback(params: {
|
||||
text: formattedErrorCandidate,
|
||||
sessionCtx: params.sessionCtx,
|
||||
isGenericRunnerFailure: false,
|
||||
suppressInNonDirect: true,
|
||||
cfg: params.followupRun.run.config,
|
||||
}),
|
||||
isError: true,
|
||||
|
||||
@@ -55,6 +55,7 @@ export function buildEmbeddedRunBaseParams(params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
runId: string;
|
||||
promptCacheKey?: string;
|
||||
authProfile: ReturnType<typeof resolveProviderScopedAuthProfile>;
|
||||
allowTransientCooldownProbe?: boolean;
|
||||
isReasoningTagProvider?: ReasoningTagProviderResolver;
|
||||
@@ -99,6 +100,7 @@ export function buildEmbeddedRunBaseParams(params: {
|
||||
bashElevated: params.run.bashElevated,
|
||||
timeoutMs: params.run.timeoutMs,
|
||||
runId: params.runId,
|
||||
promptCacheKey: params.promptCacheKey,
|
||||
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ const {
|
||||
buildThreadingToolContext,
|
||||
buildEmbeddedRunBaseParams,
|
||||
buildEmbeddedRunContexts,
|
||||
buildEmbeddedRunExecutionParams,
|
||||
resolveModelFallbackOptions,
|
||||
resolveEnforceFinalTag,
|
||||
resolveProviderScopedAuthProfile,
|
||||
@@ -138,6 +139,7 @@ describe("agent-runner-utils", () => {
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
runId: "run-1",
|
||||
promptCacheKey: "webchat-cache-key",
|
||||
authProfile,
|
||||
});
|
||||
|
||||
@@ -160,6 +162,24 @@ describe("agent-runner-utils", () => {
|
||||
expect(resolved.bashElevated).toBe(run.bashElevated);
|
||||
expect(resolved.timeoutMs).toBe(run.timeoutMs);
|
||||
expect(resolved.runId).toBe("run-1");
|
||||
expect(resolved.promptCacheKey).toBe("webchat-cache-key");
|
||||
});
|
||||
|
||||
it("threads prompt cache affinity through embedded execution params", () => {
|
||||
const run = makeRun();
|
||||
|
||||
const resolved = buildEmbeddedRunExecutionParams({
|
||||
run,
|
||||
sessionCtx: { Provider: "webchat" },
|
||||
hasRepliedRef: undefined,
|
||||
provider: "openai",
|
||||
model: "gpt-4.1-mini",
|
||||
runId: "run-1",
|
||||
promptCacheKey: "stable-session-cache-key",
|
||||
});
|
||||
|
||||
expect(resolved.runBaseParams.runId).toBe("run-1");
|
||||
expect(resolved.runBaseParams.promptCacheKey).toBe("stable-session-cache-key");
|
||||
});
|
||||
|
||||
it("passes through recovered auto fallback provenance for embedded run params", () => {
|
||||
|
||||
@@ -273,6 +273,7 @@ export function buildEmbeddedRunExecutionParams(params: {
|
||||
provider: string;
|
||||
model: string;
|
||||
runId: string;
|
||||
promptCacheKey?: string;
|
||||
allowTransientCooldownProbe?: boolean;
|
||||
}) {
|
||||
const { authProfile, embeddedContext, senderContext } = buildEmbeddedRunContexts(params);
|
||||
@@ -281,6 +282,7 @@ export function buildEmbeddedRunExecutionParams(params: {
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
runId: params.runId,
|
||||
promptCacheKey: params.promptCacheKey,
|
||||
authProfile,
|
||||
allowTransientCooldownProbe: params.allowTransientCooldownProbe,
|
||||
});
|
||||
|
||||
@@ -0,0 +1,159 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createChannelProgressDraftCompositor } from "./progress-draft-compositor.js";
|
||||
|
||||
describe("createChannelProgressDraftCompositor", () => {
|
||||
it("keeps the progress label visible when tool lines are hidden", async () => {
|
||||
const update = vi.fn();
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: {
|
||||
streaming: { mode: "progress", progress: { label: "Shelling", toolProgress: false } },
|
||||
},
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "test",
|
||||
update,
|
||||
});
|
||||
|
||||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||||
|
||||
expect(update).toHaveBeenCalledWith("Shelling", { flush: true });
|
||||
});
|
||||
|
||||
it("keeps reasoning details hidden when tool progress lines are hidden", async () => {
|
||||
const update = vi.fn();
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: {
|
||||
streaming: { mode: "progress", progress: { label: "Shelling", toolProgress: false } },
|
||||
},
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "test",
|
||||
update,
|
||||
});
|
||||
|
||||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||||
await progress.pushReasoningProgress("Reading files");
|
||||
|
||||
expect(update).toHaveBeenCalledWith("Shelling", { flush: true });
|
||||
expect(update).not.toHaveBeenCalledWith(expect.stringContaining("Reading"), undefined);
|
||||
});
|
||||
|
||||
it("does not resurrect progress after suppression", async () => {
|
||||
const update = vi.fn();
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "test",
|
||||
update,
|
||||
});
|
||||
|
||||
progress.suppress();
|
||||
await progress.pushReasoningProgress("Reading files");
|
||||
|
||||
expect(update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("composes reasoning deltas with tool progress", async () => {
|
||||
const update = vi.fn();
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "test",
|
||||
update,
|
||||
});
|
||||
|
||||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||||
await progress.pushReasoningProgress("Reading");
|
||||
await progress.pushReasoningProgress(" files");
|
||||
|
||||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Reading files_", undefined);
|
||||
});
|
||||
|
||||
it("preserves tagged reasoning content without leaking tags", async () => {
|
||||
const update = vi.fn();
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "test",
|
||||
update,
|
||||
});
|
||||
|
||||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||||
await progress.pushReasoningProgress("<think>Checking files</think>Final answer prose");
|
||||
|
||||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Checking files_", undefined);
|
||||
});
|
||||
|
||||
it("waits for complete reasoning tags before showing tagged progress", async () => {
|
||||
const update = vi.fn();
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "test",
|
||||
update,
|
||||
});
|
||||
|
||||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||||
const calls = update.mock.calls.length;
|
||||
await progress.pushReasoningProgress("<thin");
|
||||
|
||||
expect(update.mock.calls).toHaveLength(calls);
|
||||
});
|
||||
|
||||
it("preserves partial reasoning tag buffers across deltas", async () => {
|
||||
const update = vi.fn();
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "test",
|
||||
update,
|
||||
});
|
||||
|
||||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||||
await progress.pushReasoningProgress("<thin");
|
||||
await progress.pushReasoningProgress("k>Checking files</think>Final answer prose");
|
||||
|
||||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Checking files_", undefined);
|
||||
});
|
||||
|
||||
it("keeps literal reasoning tags inside code blocks", async () => {
|
||||
const update = vi.fn();
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "test",
|
||||
update,
|
||||
});
|
||||
|
||||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||||
await progress.pushReasoningProgress("```html\n<think>literal</think>\n```");
|
||||
|
||||
expect(update).toHaveBeenLastCalledWith(
|
||||
"Shelling\n\n🛠️ Exec\n• _```html <think>literal</think> ```_",
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("replaces repeated formatted reasoning snapshots", async () => {
|
||||
const update = vi.fn();
|
||||
const progress = createChannelProgressDraftCompositor({
|
||||
entry: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
mode: "progress",
|
||||
active: true,
|
||||
seed: "test",
|
||||
update,
|
||||
});
|
||||
|
||||
await progress.pushToolProgress("🛠️ Exec", { startImmediately: true });
|
||||
await progress.pushReasoningProgress("Thinking\n\n_Reading_");
|
||||
await progress.pushReasoningProgress("Thinking\n\n_Reading files_");
|
||||
|
||||
expect(update).toHaveBeenLastCalledWith("Shelling\n\n🛠️ Exec\n• _Reading files_", undefined);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,479 @@
|
||||
import { formatReasoningMessage } from "../agents/embedded-agent-utils.js";
|
||||
import { findCodeRegions, isInsideCode } from "../shared/text/code-regions.js";
|
||||
import { stripInlineDirectiveTagsForDelivery } from "../utils/directive-tags.js";
|
||||
import { removeChannelProgressDraftLine } from "./progress-draft-lines.js";
|
||||
import {
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
mergeChannelProgressDraftLine,
|
||||
normalizeChannelProgressDraftLineIdentity,
|
||||
resolveChannelProgressDraftMaxLineChars,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
resolveChannelStreamingProgressCommentary,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||
type StreamingCompatEntry,
|
||||
type StreamingMode,
|
||||
} from "./streaming.js";
|
||||
|
||||
export type ChannelProgressDraftMode = StreamingMode;
|
||||
|
||||
export type ChannelProgressDraftCompositor = ReturnType<
|
||||
typeof createChannelProgressDraftCompositor
|
||||
>;
|
||||
type ProgressDraftLine = string | ChannelProgressDraftLine;
|
||||
|
||||
export function createChannelProgressDraftCompositor(params: {
|
||||
entry: StreamingCompatEntry | null | undefined;
|
||||
mode: ChannelProgressDraftMode;
|
||||
active: boolean;
|
||||
seed: string;
|
||||
update: (text: string, options?: { flush?: boolean }) => Promise<void> | void;
|
||||
deleteCurrent?: () => Promise<void> | void;
|
||||
tryNativeUpdate?: (text: string) => Promise<boolean> | boolean;
|
||||
formatLine?: (line: string) => string;
|
||||
isEmptyLine?: (line: ProgressDraftLine | undefined) => boolean;
|
||||
shouldStartNow?: (line: ProgressDraftLine | undefined) => boolean;
|
||||
}) {
|
||||
const previewToolProgressEnabled =
|
||||
params.active && resolveChannelStreamingPreviewToolProgress(params.entry);
|
||||
const commentaryProgressEnabled =
|
||||
params.active && resolveChannelStreamingProgressCommentary(params.entry);
|
||||
const suppressDefaultToolProgressMessages =
|
||||
params.active &&
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.entry, {
|
||||
draftStreamActive: true,
|
||||
previewToolProgressEnabled,
|
||||
});
|
||||
let progressSuppressed = false;
|
||||
let lines: ProgressDraftLine[] = [];
|
||||
let lastRenderedText = "";
|
||||
let reasoningRawText = "";
|
||||
let lastReasoningLine: string | undefined;
|
||||
let finalReplyStarted = false;
|
||||
let finalReplyDelivered = false;
|
||||
|
||||
const formatDraftText = (draftLines = lines, options?: { formatted?: boolean }) =>
|
||||
formatChannelProgressDraftText({
|
||||
entry: params.entry,
|
||||
lines: draftLines,
|
||||
seed: params.seed,
|
||||
formatLine: options?.formatted === false ? undefined : params.formatLine,
|
||||
});
|
||||
|
||||
const clearProgressState = (suppressed: boolean) => {
|
||||
progressSuppressed = suppressed;
|
||||
lines = [];
|
||||
lastRenderedText = "";
|
||||
reasoningRawText = "";
|
||||
lastReasoningLine = undefined;
|
||||
};
|
||||
|
||||
const render = async (options?: { flush?: boolean }): Promise<boolean> => {
|
||||
if (!params.active || params.mode !== "progress") {
|
||||
return false;
|
||||
}
|
||||
const text = formatDraftText();
|
||||
if (!text || text === lastRenderedText) {
|
||||
return false;
|
||||
}
|
||||
lastRenderedText = text;
|
||||
await params.update(text, options);
|
||||
return true;
|
||||
};
|
||||
|
||||
const gate = createChannelProgressDraftGate({
|
||||
onStart: async () => {
|
||||
await render({ flush: true });
|
||||
},
|
||||
});
|
||||
|
||||
const clearLine = async (lineId: string) => {
|
||||
const nextLines = removeChannelProgressDraftLine(lines, lineId);
|
||||
if (nextLines === lines) {
|
||||
return;
|
||||
}
|
||||
lines = nextLines;
|
||||
if (!gate.hasStarted) {
|
||||
return;
|
||||
}
|
||||
const text = formatDraftText();
|
||||
if (text) {
|
||||
await render();
|
||||
return;
|
||||
}
|
||||
lastRenderedText = "";
|
||||
await params.deleteCurrent?.();
|
||||
};
|
||||
|
||||
const noteProgress = async (
|
||||
line?: ProgressDraftLine,
|
||||
options?: { toolName?: string; startImmediately?: boolean },
|
||||
) => {
|
||||
if (!params.active || finalReplyStarted || finalReplyDelivered) {
|
||||
return false;
|
||||
}
|
||||
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
|
||||
return false;
|
||||
}
|
||||
if (params.isEmptyLine?.(line)) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeChannelProgressDraftLineIdentity(line);
|
||||
if (!normalized || progressSuppressed) {
|
||||
return false;
|
||||
}
|
||||
if (params.mode !== "progress" && !previewToolProgressEnabled) {
|
||||
return false;
|
||||
}
|
||||
const progressLine = typeof line === "object" && line !== undefined ? line : normalized;
|
||||
const shouldStoreLine = previewToolProgressEnabled;
|
||||
const nextLines = shouldStoreLine
|
||||
? mergeChannelProgressDraftLine(lines, progressLine, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
|
||||
})
|
||||
: lines;
|
||||
if (shouldStoreLine && nextLines === lines) {
|
||||
return false;
|
||||
}
|
||||
if (shouldStoreLine && params.tryNativeUpdate) {
|
||||
const text = formatDraftText(nextLines, { formatted: false });
|
||||
if (text && (await params.tryNativeUpdate(text))) {
|
||||
lines = nextLines;
|
||||
lastRenderedText = text;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
lines = nextLines;
|
||||
if (params.mode !== "progress") {
|
||||
if (!shouldStoreLine) {
|
||||
return false;
|
||||
}
|
||||
const text = formatDraftText();
|
||||
if (!text || text === lastRenderedText) {
|
||||
return false;
|
||||
}
|
||||
lastRenderedText = text;
|
||||
await params.update(text);
|
||||
return true;
|
||||
}
|
||||
if (options?.startImmediately || params.shouldStartNow?.(line)) {
|
||||
await gate.startNow();
|
||||
return gate.hasStarted ? await render() : false;
|
||||
}
|
||||
const alreadyStarted = gate.hasStarted;
|
||||
const progressActive = await gate.noteWork();
|
||||
if ((alreadyStarted || progressActive) && gate.hasStarted) {
|
||||
return await render();
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
return {
|
||||
get previewToolProgressEnabled() {
|
||||
return previewToolProgressEnabled;
|
||||
},
|
||||
get commentaryProgressEnabled() {
|
||||
return commentaryProgressEnabled;
|
||||
},
|
||||
get suppressDefaultToolProgressMessages() {
|
||||
return suppressDefaultToolProgressMessages;
|
||||
},
|
||||
get hasStarted() {
|
||||
return gate.hasStarted;
|
||||
},
|
||||
markFinalReplyStarted() {
|
||||
finalReplyStarted = true;
|
||||
},
|
||||
markFinalReplyDelivered() {
|
||||
finalReplyDelivered = true;
|
||||
},
|
||||
reset() {
|
||||
clearProgressState(false);
|
||||
},
|
||||
suppress() {
|
||||
clearProgressState(true);
|
||||
},
|
||||
cancel() {
|
||||
gate.cancel();
|
||||
},
|
||||
start() {
|
||||
return gate.startNow();
|
||||
},
|
||||
pushToolProgress: noteProgress,
|
||||
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
|
||||
if (
|
||||
!params.active ||
|
||||
params.mode !== "progress" ||
|
||||
!text ||
|
||||
progressSuppressed ||
|
||||
finalReplyDelivered
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
reasoningRawText = mergeReasoningProgressText(reasoningRawText, text, {
|
||||
snapshot: options?.snapshot === true,
|
||||
});
|
||||
const normalized = normalizeReasoningProgressLine(reasoningRawText);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const displayLine = formatReasoningProgressDisplayLine(
|
||||
normalized,
|
||||
resolveChannelProgressDraftMaxLineChars(params.entry),
|
||||
);
|
||||
if (!displayLine) {
|
||||
return false;
|
||||
}
|
||||
if (previewToolProgressEnabled) {
|
||||
const priorIndex =
|
||||
lastReasoningLine === undefined ? -1 : lines.lastIndexOf(lastReasoningLine);
|
||||
if (priorIndex >= 0) {
|
||||
lines = [...lines];
|
||||
lines[priorIndex] = displayLine;
|
||||
} else {
|
||||
lines = [...lines, displayLine].slice(-resolveChannelProgressDraftMaxLines(params.entry));
|
||||
}
|
||||
lastReasoningLine = displayLine;
|
||||
}
|
||||
const progressActive = await gate.noteWork();
|
||||
if (progressActive && gate.hasStarted) {
|
||||
return await render();
|
||||
}
|
||||
return false;
|
||||
},
|
||||
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
|
||||
if (!params.active || params.mode !== "progress" || !commentaryProgressEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (finalReplyStarted || finalReplyDelivered) {
|
||||
return false;
|
||||
}
|
||||
const itemId = options?.itemId?.trim();
|
||||
if (!text && !itemId) {
|
||||
return false;
|
||||
}
|
||||
const normalized = normalizeCommentaryProgressText(text ?? "");
|
||||
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
|
||||
if (!normalized) {
|
||||
if (lineId) {
|
||||
await clearLine(lineId);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
const line: ChannelProgressDraftLine = {
|
||||
id: lineId,
|
||||
kind: "item",
|
||||
text: normalized,
|
||||
label: "Commentary",
|
||||
prefix: false,
|
||||
};
|
||||
lines = mergeChannelProgressDraftLine(lines, line, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.entry),
|
||||
});
|
||||
await gate.startNow();
|
||||
return await render();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressLine(text: string): string {
|
||||
const reasoningText = readReasoningProgressTextOutsideCode(text);
|
||||
if (reasoningText === undefined) {
|
||||
return "";
|
||||
}
|
||||
return stripReasoningProgressTagsOutsideCode(reasoningText)
|
||||
.replace(
|
||||
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
|
||||
"",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
const REASONING_PROGRESS_TAG_RE =
|
||||
/<\s*(\/?)\s*(?:(?:antml:)?(?:think(?:ing)?|thought)|antthinking)\b[^<>]*>/giu;
|
||||
const REASONING_PROGRESS_TAG_NAMES = [
|
||||
"think",
|
||||
"thinking",
|
||||
"thought",
|
||||
"antthinking",
|
||||
"antml:think",
|
||||
"antml:thinking",
|
||||
"antml:thought",
|
||||
] as const;
|
||||
const REASONING_PROGRESS_TAG_PREFIXES = REASONING_PROGRESS_TAG_NAMES.flatMap((name) => [
|
||||
`<${name}`,
|
||||
`</${name}`,
|
||||
]);
|
||||
|
||||
function readReasoningProgressTextOutsideCode(text: string): string | undefined {
|
||||
if (isPartialReasoningProgressTagPrefix(text)) {
|
||||
return undefined;
|
||||
}
|
||||
const codeRegions = findCodeRegions(text);
|
||||
let hasTags = false;
|
||||
let inReasoning = false;
|
||||
let cursor = 0;
|
||||
const chunks: string[] = [];
|
||||
for (const match of text.matchAll(REASONING_PROGRESS_TAG_RE)) {
|
||||
const offset = match.index ?? 0;
|
||||
if (isInsideCode(offset, codeRegions)) {
|
||||
continue;
|
||||
}
|
||||
hasTags = true;
|
||||
if (match[1]) {
|
||||
if (inReasoning) {
|
||||
chunks.push(text.slice(cursor, offset));
|
||||
}
|
||||
inReasoning = false;
|
||||
cursor = offset + match[0].length;
|
||||
continue;
|
||||
}
|
||||
if (inReasoning) {
|
||||
chunks.push(text.slice(cursor, offset));
|
||||
}
|
||||
inReasoning = true;
|
||||
cursor = offset + match[0].length;
|
||||
}
|
||||
if (!hasTags) {
|
||||
return text;
|
||||
}
|
||||
if (inReasoning) {
|
||||
chunks.push(text.slice(cursor));
|
||||
}
|
||||
return chunks.join("").trim();
|
||||
}
|
||||
|
||||
function isPartialReasoningProgressTagPrefix(text: string): boolean {
|
||||
const normalized = text.trimStart().toLowerCase();
|
||||
return (
|
||||
normalized.startsWith("<") &&
|
||||
!normalized.includes(">") &&
|
||||
REASONING_PROGRESS_TAG_PREFIXES.some(
|
||||
(prefix) => prefix.startsWith(normalized) || normalized.startsWith(prefix),
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function stripReasoningProgressTagsOutsideCode(text: string): string {
|
||||
const codeRegions = findCodeRegions(text);
|
||||
return text.replace(REASONING_PROGRESS_TAG_RE, (match, _closing: string, offset: number) =>
|
||||
isInsideCode(offset, codeRegions) ? match : "",
|
||||
);
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressInput(text: string): string {
|
||||
const normalized = normalizeReasoningProgressLine(text);
|
||||
const italic = normalized.match(/^_(.*)_$/u);
|
||||
return (italic?.[1] ?? normalized).trim();
|
||||
}
|
||||
|
||||
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalizedText = normalizeReasoningProgressInput(text);
|
||||
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
|
||||
if (!formatted) {
|
||||
return "";
|
||||
}
|
||||
if (Array.from(formatted).length <= maxChars) {
|
||||
return formatted;
|
||||
}
|
||||
const italic = formatted.match(/^_(.*)_$/u);
|
||||
if (!italic) {
|
||||
return compactReasoningProgressDisplayLine(formatted, maxChars);
|
||||
}
|
||||
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
|
||||
return body ? `_${body}_` : "";
|
||||
}
|
||||
|
||||
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
const chars = Array.from(normalized);
|
||||
if (chars.length <= maxChars) {
|
||||
return normalized;
|
||||
}
|
||||
if (maxChars <= 1) {
|
||||
return "…";
|
||||
}
|
||||
const head = chars
|
||||
.slice(0, maxChars - 1)
|
||||
.join("")
|
||||
.trimEnd();
|
||||
const boundary = head.search(/\s+\S*$/u);
|
||||
if (boundary > Math.floor(maxChars * 0.6)) {
|
||||
return `${head.slice(0, boundary).trimEnd()}…`;
|
||||
}
|
||||
return `${head}…`;
|
||||
}
|
||||
|
||||
function normalizeCommentaryProgressText(text: string): string {
|
||||
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
|
||||
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
|
||||
return "";
|
||||
}
|
||||
return cleaned
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => `_${line}_`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isSilentCommentaryProgressText(text: string): boolean {
|
||||
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
|
||||
return /^NO_REPLY$/iu.test(normalized);
|
||||
}
|
||||
|
||||
function mergeReasoningProgressText(
|
||||
current: string,
|
||||
incoming: string,
|
||||
options?: { snapshot?: boolean },
|
||||
): string {
|
||||
if (!current) {
|
||||
return incoming;
|
||||
}
|
||||
const normalizedCurrent = normalizeReasoningProgressInput(current);
|
||||
const normalizedIncoming = normalizeReasoningProgressInput(incoming);
|
||||
if (!normalizedIncoming) {
|
||||
return shouldAppendEmptyReasoningProgressDelta(current, incoming)
|
||||
? `${current}${incoming}`
|
||||
: current;
|
||||
}
|
||||
if (normalizedIncoming === normalizedCurrent) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
options?.snapshot === true ||
|
||||
isReasoningSnapshotText(incoming) ||
|
||||
(normalizedCurrent && normalizedIncoming.startsWith(normalizedCurrent))
|
||||
) {
|
||||
return incoming;
|
||||
}
|
||||
return `${current}${incoming}`;
|
||||
}
|
||||
|
||||
function isReasoningSnapshotText(text: string): boolean {
|
||||
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
function shouldAppendEmptyReasoningProgressDelta(current: string, incoming: string): boolean {
|
||||
return (
|
||||
isPartialReasoningProgressTagPrefix(current) ||
|
||||
isPartialReasoningProgressTagPrefix(incoming) ||
|
||||
hasReasoningProgressTagOutsideCode(incoming)
|
||||
);
|
||||
}
|
||||
|
||||
function hasReasoningProgressTagOutsideCode(text: string): boolean {
|
||||
const codeRegions = findCodeRegions(text);
|
||||
for (const match of text.matchAll(REASONING_PROGRESS_TAG_RE)) {
|
||||
if (!isInsideCode(match.index ?? 0, codeRegions)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { removeChannelProgressDraftLine } from "./progress-draft-lines.js";
|
||||
import { buildChannelProgressDraftLine } from "./streaming.js";
|
||||
|
||||
describe("progress draft lines", () => {
|
||||
it("removes keyed progress lines in place", () => {
|
||||
const line = buildChannelProgressDraftLine({
|
||||
event: "item",
|
||||
itemId: "preamble-1",
|
||||
itemKind: "preamble",
|
||||
title: "Preamble",
|
||||
progressText: "Checking the app-server stream",
|
||||
});
|
||||
if (!line) {
|
||||
throw new Error("expected preamble progress line");
|
||||
}
|
||||
const lines: Array<string | typeof line> = ["🛠️ Exec", line];
|
||||
|
||||
expect(removeChannelProgressDraftLine(lines, "preamble-1")).toEqual(["🛠️ Exec"]);
|
||||
expect(removeChannelProgressDraftLine(lines, "missing")).toBe(lines);
|
||||
expect(removeChannelProgressDraftLine(lines, " ")).toBe(lines);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { ChannelProgressDraftLine } from "./streaming.js";
|
||||
|
||||
export type ProgressDraftLine = string | ChannelProgressDraftLine;
|
||||
|
||||
export function removeChannelProgressDraftLine<TLine extends ProgressDraftLine>(
|
||||
lines: TLine[],
|
||||
id: string,
|
||||
): TLine[] {
|
||||
const lineId = id.trim();
|
||||
if (!lineId) {
|
||||
return lines;
|
||||
}
|
||||
const next = lines.filter((line) => typeof line !== "object" || line.id?.trim() !== lineId);
|
||||
return next.length === lines.length ? lines : next;
|
||||
}
|
||||
@@ -26,7 +26,7 @@ export type {
|
||||
} from "../config/types.base.js";
|
||||
export type { SlackChannelStreamingConfig } from "../config/types.slack.js";
|
||||
|
||||
type StreamingCompatEntry = {
|
||||
export type StreamingCompatEntry = {
|
||||
streaming?: unknown;
|
||||
streamMode?: unknown;
|
||||
chunkMode?: unknown;
|
||||
|
||||
@@ -1,5 +1,16 @@
|
||||
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
|
||||
|
||||
const TOOL_USE_ID_FIELDS = [
|
||||
"id",
|
||||
"tool_call_id",
|
||||
"toolCallId",
|
||||
"tool_use_id",
|
||||
"toolUseId",
|
||||
] as const;
|
||||
type ToolUseIdField = (typeof TOOL_USE_ID_FIELDS)[number];
|
||||
|
||||
/** Provider-agnostic chat content block shape used before SDK-specific narrowing. */
|
||||
export type ToolContentBlock = Record<string, unknown>;
|
||||
export type ToolContentBlock = Record<string, unknown> & Partial<Record<ToolUseIdField, unknown>>;
|
||||
|
||||
function normalizeToolContentType(value: unknown): string {
|
||||
return typeof value === "string" ? value.toLowerCase() : "";
|
||||
@@ -34,9 +45,11 @@ export function resolveToolBlockArgs(block: ToolContentBlock): unknown {
|
||||
|
||||
/** Reads the stable tool-use id across snake_case and camelCase provider field names. */
|
||||
export function resolveToolUseId(block: ToolContentBlock): string | undefined {
|
||||
const id =
|
||||
(typeof block.id === "string" && block.id.trim()) ||
|
||||
(typeof block.tool_use_id === "string" && block.tool_use_id.trim()) ||
|
||||
(typeof block.toolUseId === "string" && block.toolUseId.trim());
|
||||
return id || undefined;
|
||||
for (const field of TOOL_USE_ID_FIELDS) {
|
||||
const id = normalizeOptionalString(block[field]);
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@@ -1,14 +1,34 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/config.js";
|
||||
import {
|
||||
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
|
||||
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
|
||||
} from "./gateway-health-auth-diagnostic.js";
|
||||
|
||||
const callGateway = vi.hoisted(() => vi.fn());
|
||||
const isGatewayCredentialsRequiredError = vi.hoisted(() => vi.fn(() => false));
|
||||
const probeGatewayStatus = vi.hoisted(() => vi.fn());
|
||||
const note = vi.hoisted(() => vi.fn());
|
||||
const TEST_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||
const TEST_AUTH_CLOSE_ERROR = "gateway closed (1008):";
|
||||
const TEST_TLS_FINGERPRINT = "sha256:test-doctor-gateway-fingerprint";
|
||||
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
buildGatewayConnectionDetails: vi.fn(() => ({
|
||||
message: "Gateway target: ws://127.0.0.1:18789",
|
||||
message: `Gateway target: ${TEST_GATEWAY_URL}`,
|
||||
url: TEST_GATEWAY_URL,
|
||||
})),
|
||||
buildGatewayProbeConnectionDetails: vi.fn(() => ({
|
||||
preauthHandshakeTimeoutMs: 4321,
|
||||
tlsFingerprint: TEST_TLS_FINGERPRINT,
|
||||
url: TEST_GATEWAY_URL,
|
||||
})),
|
||||
callGateway,
|
||||
isGatewayCredentialsRequiredError,
|
||||
}));
|
||||
|
||||
vi.mock("../cli/daemon-cli/probe.js", () => ({
|
||||
probeGatewayStatus,
|
||||
}));
|
||||
|
||||
vi.mock("../../packages/terminal-core/src/note.js", () => ({
|
||||
@@ -26,6 +46,9 @@ describe("checkGatewayHealth", () => {
|
||||
|
||||
beforeEach(() => {
|
||||
callGateway.mockReset();
|
||||
isGatewayCredentialsRequiredError.mockReset();
|
||||
isGatewayCredentialsRequiredError.mockReturnValue(false);
|
||||
probeGatewayStatus.mockReset();
|
||||
note.mockReset();
|
||||
});
|
||||
|
||||
@@ -35,7 +58,7 @@ describe("checkGatewayHealth", () => {
|
||||
|
||||
await expect(
|
||||
checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }),
|
||||
).resolves.toEqual({ healthOk: true, status: { ok: true } });
|
||||
).resolves.toEqual({ authenticated: true, healthOk: true, status: { ok: true } });
|
||||
|
||||
expect(callGateway).toHaveBeenNthCalledWith(1, {
|
||||
method: "status",
|
||||
@@ -58,7 +81,11 @@ describe("checkGatewayHealth", () => {
|
||||
|
||||
await expect(
|
||||
checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }),
|
||||
).resolves.toEqual({ healthOk: true, status: { runtimeVersion: "2026.4.23" } });
|
||||
).resolves.toEqual({
|
||||
authenticated: true,
|
||||
healthOk: true,
|
||||
status: { runtimeVersion: "2026.4.23" },
|
||||
});
|
||||
|
||||
const mismatchNotes = note.mock.calls
|
||||
.filter(([, title]) => title === "OpenClaw version mismatch")
|
||||
@@ -78,13 +105,43 @@ describe("checkGatewayHealth", () => {
|
||||
|
||||
await expect(
|
||||
checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }),
|
||||
).resolves.toEqual({ healthOk: false });
|
||||
).resolves.toEqual({ authenticated: false, healthOk: false, status: undefined });
|
||||
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining("gateway timeout after 3000ms"),
|
||||
);
|
||||
});
|
||||
|
||||
it("reports credentials-required when status RPC auth blocks a reachable gateway", async () => {
|
||||
callGateway.mockRejectedValueOnce(new Error());
|
||||
isGatewayCredentialsRequiredError.mockReturnValueOnce(true);
|
||||
probeGatewayStatus.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
kind: "connect",
|
||||
error: TEST_AUTH_CLOSE_ERROR,
|
||||
});
|
||||
const runtime = { log: vi.fn(), error: vi.fn(), exit: vi.fn() };
|
||||
|
||||
await expect(
|
||||
checkGatewayHealth({ runtime: runtime as never, cfg, timeoutMs: 3000 }),
|
||||
).resolves.toEqual({ authenticated: false, healthOk: true });
|
||||
|
||||
expect(probeGatewayStatus).toHaveBeenCalledWith({
|
||||
url: TEST_GATEWAY_URL,
|
||||
timeoutMs: 3000,
|
||||
tlsFingerprint: TEST_TLS_FINGERPRINT,
|
||||
preauthHandshakeTimeoutMs: 4321,
|
||||
config: cfg,
|
||||
json: true,
|
||||
});
|
||||
expect(runtime.error).not.toHaveBeenCalled();
|
||||
expect(note).toHaveBeenCalledWith(
|
||||
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
|
||||
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
|
||||
);
|
||||
expect(callGateway).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("probeGatewayMemoryStatus", () => {
|
||||
@@ -116,7 +173,7 @@ describe("probeGatewayMemoryStatus", () => {
|
||||
// A transport timeout must NOT be treated as a skipped probe. It is a real
|
||||
// diagnostic signal and the renderer should warn for key-optional providers.
|
||||
callGateway.mockRejectedValue(
|
||||
new Error("gateway timeout after 8000ms\nGateway target: ws://127.0.0.1:18789"),
|
||||
new Error(`gateway timeout after 8000ms\nGateway target: ${TEST_GATEWAY_URL}`),
|
||||
);
|
||||
|
||||
const result = await probeGatewayMemoryStatus({ cfg });
|
||||
|
||||
@@ -1,11 +1,22 @@
|
||||
import { note } from "../../packages/terminal-core/src/note.js";
|
||||
import { probeGatewayStatus } from "../cli/daemon-cli/probe.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { buildGatewayConnectionDetails, callGateway } from "../gateway/call.js";
|
||||
import {
|
||||
buildGatewayConnectionDetails,
|
||||
buildGatewayProbeConnectionDetails,
|
||||
callGateway,
|
||||
isGatewayCredentialsRequiredError,
|
||||
} from "../gateway/call.js";
|
||||
import type { DoctorMemoryStatusPayload } from "../gateway/server-methods/doctor.js";
|
||||
import { collectChannelStatusIssues } from "../infra/channels-status-issues.js";
|
||||
import { formatErrorMessage } from "../infra/errors.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { VERSION } from "../version.js";
|
||||
import {
|
||||
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
|
||||
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
|
||||
gatewayProbeResultSawGateway,
|
||||
} from "./gateway-health-auth-diagnostic.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
import type { StatusSummary } from "./status.types.js";
|
||||
|
||||
@@ -45,8 +56,7 @@ export async function checkGatewayHealth(params: {
|
||||
runtime: RuntimeEnv;
|
||||
cfg: OpenClawConfig;
|
||||
timeoutMs?: number;
|
||||
}): Promise<{ healthOk: boolean; status?: StatusSummary }> {
|
||||
const gatewayDetails = buildGatewayConnectionDetails({ config: params.cfg });
|
||||
}): Promise<{ healthOk: boolean; authenticated: boolean; status?: StatusSummary }> {
|
||||
const timeoutMs =
|
||||
typeof params.timeoutMs === "number" && params.timeoutMs > 0 ? params.timeoutMs : 10_000;
|
||||
let healthOk = false;
|
||||
@@ -60,17 +70,6 @@ export async function checkGatewayHealth(params: {
|
||||
});
|
||||
healthOk = true;
|
||||
noteCliGatewayVersionSkew(status);
|
||||
} catch (err) {
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
note("Gateway not running.", "Gateway");
|
||||
note(gatewayDetails.message, "Gateway connection");
|
||||
} else {
|
||||
params.runtime.error(formatHealthCheckFailure(err));
|
||||
}
|
||||
}
|
||||
|
||||
if (healthOk) {
|
||||
try {
|
||||
const statusLocal = await callGateway({
|
||||
method: "channels.status",
|
||||
@@ -94,9 +93,38 @@ export async function checkGatewayHealth(params: {
|
||||
} catch {
|
||||
// ignore: doctor already reported gateway health
|
||||
}
|
||||
return { healthOk, authenticated: true, status };
|
||||
} catch (err) {
|
||||
if (isGatewayCredentialsRequiredError(err)) {
|
||||
const probeDetails = await buildGatewayProbeConnectionDetails({ config: params.cfg });
|
||||
const probe = await probeGatewayStatus({
|
||||
url: probeDetails.url,
|
||||
timeoutMs,
|
||||
tlsFingerprint: probeDetails.tlsFingerprint,
|
||||
preauthHandshakeTimeoutMs: probeDetails.preauthHandshakeTimeoutMs,
|
||||
config: params.cfg,
|
||||
json: true,
|
||||
});
|
||||
if (gatewayProbeResultSawGateway(probe)) {
|
||||
note(
|
||||
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
|
||||
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE,
|
||||
);
|
||||
healthOk = true;
|
||||
return { healthOk, authenticated: false };
|
||||
}
|
||||
}
|
||||
const message = String(err);
|
||||
if (message.includes("gateway closed")) {
|
||||
const gatewayDetails = buildGatewayConnectionDetails({ config: params.cfg });
|
||||
note("Gateway not running.", "Gateway");
|
||||
note(gatewayDetails.message, "Gateway connection");
|
||||
} else {
|
||||
params.runtime.error(formatHealthCheckFailure(err));
|
||||
}
|
||||
}
|
||||
|
||||
return { healthOk, status };
|
||||
return { healthOk, authenticated: false, status };
|
||||
}
|
||||
|
||||
export async function probeGatewayMemoryStatus(params: {
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { DaemonStatus } from "../cli/daemon-cli/status.gather.js";
|
||||
|
||||
type GatewayProbeReachabilityEvidence = NonNullable<DaemonStatus["rpc"]>;
|
||||
|
||||
export const GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE =
|
||||
"Gateway is reachable, but this CLI has no token/password or paired device token for read-scope health RPCs.";
|
||||
export const GATEWAY_HEALTH_CREDENTIALS_REQUIRED_TITLE = "Gateway credentials required";
|
||||
export const GATEWAY_HEALTH_REACHABLE_LINE = "Gateway: reachable";
|
||||
|
||||
export function gatewayProbeResultSawGateway(status: GatewayProbeReachabilityEvidence): boolean {
|
||||
if (status.ok) {
|
||||
return true;
|
||||
}
|
||||
const auth = status.auth;
|
||||
if (auth?.capability && auth.capability !== "unknown") {
|
||||
return true;
|
||||
}
|
||||
if (auth?.role || (auth?.scopes?.length ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
const server = status.server;
|
||||
if (server?.version || server?.connId) {
|
||||
return true;
|
||||
}
|
||||
return /\bgateway closed \(\d+\):|\bpairing required\b|\bdevice identity required\b/i.test(
|
||||
status.error ?? "",
|
||||
);
|
||||
}
|
||||
|
||||
export function buildCredentialsRequiredHealthDiagnostic() {
|
||||
return {
|
||||
ok: false,
|
||||
error: {
|
||||
type: "gateway_credentials_required",
|
||||
message: GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
|
||||
},
|
||||
gateway: {
|
||||
reachable: true,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import type { DaemonStatus } from "../cli/daemon-cli/status.gather.js";
|
||||
import { promptYesNo } from "../cli/prompt.js";
|
||||
import type { RuntimeEnv } from "../runtime.js";
|
||||
import { createLazyImportLoader } from "../shared/lazy-promise.js";
|
||||
import { gatewayProbeResultSawGateway } from "./gateway-health-auth-diagnostic.js";
|
||||
|
||||
const daemonStatusModuleLoader = createLazyImportLoader(
|
||||
() => import("../cli/daemon-cli/status.gather.js"),
|
||||
@@ -80,25 +81,7 @@ function gatewayIsRunning(status: DaemonStatus): boolean {
|
||||
}
|
||||
|
||||
function gatewayProbeSawGateway(status: DaemonStatus): boolean {
|
||||
const rpc = status.rpc;
|
||||
if (!rpc) {
|
||||
return false;
|
||||
}
|
||||
if (rpc.ok) {
|
||||
return true;
|
||||
}
|
||||
if (rpc.auth?.capability && rpc.auth.capability !== "unknown") {
|
||||
return true;
|
||||
}
|
||||
if (rpc.auth?.role || (rpc.auth?.scopes?.length ?? 0) > 0) {
|
||||
return true;
|
||||
}
|
||||
if (rpc.server?.version || rpc.server?.connId) {
|
||||
return true;
|
||||
}
|
||||
return /\bgateway closed \(\d+\):|\bpairing required\b|\bdevice identity required\b/i.test(
|
||||
rpc.error ?? "",
|
||||
);
|
||||
return Boolean(status.rpc && gatewayProbeResultSawGateway(status.rpc));
|
||||
}
|
||||
|
||||
function gatewayLooksReachable(status: DaemonStatus): boolean {
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { stripAnsi } from "../../packages/terminal-core/src/ansi.js";
|
||||
import {
|
||||
buildCredentialsRequiredHealthDiagnostic,
|
||||
GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE,
|
||||
GATEWAY_HEALTH_REACHABLE_LINE,
|
||||
} from "./gateway-health-auth-diagnostic.js";
|
||||
import { formatHealthCheckFailure } from "./health-format.js";
|
||||
import type { HealthSummary } from "./health.js";
|
||||
import {
|
||||
@@ -57,16 +62,37 @@ const createHealthSummary = (params: {
|
||||
};
|
||||
|
||||
const callGatewayMock = vi.fn();
|
||||
const isGatewayCredentialsRequiredErrorMock = vi.fn((_value: unknown) => false);
|
||||
const TEST_GATEWAY_URL = "ws://127.0.0.1:18789";
|
||||
const TEST_GATEWAY_MESSAGE = `Gateway mode: local\nGateway target: ${TEST_GATEWAY_URL}`;
|
||||
const TEST_AUTH_CLOSE_ERROR = "gateway closed (1008):";
|
||||
const TEST_TLS_FINGERPRINT = "sha256:test-health-gateway-fingerprint";
|
||||
const buildGatewayConnectionDetailsMock = vi.fn(() => ({
|
||||
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
|
||||
message: TEST_GATEWAY_MESSAGE,
|
||||
url: TEST_GATEWAY_URL,
|
||||
}));
|
||||
const buildGatewayProbeConnectionDetailsMock = vi.fn(() => ({
|
||||
message: TEST_GATEWAY_MESSAGE,
|
||||
preauthHandshakeTimeoutMs: 4321,
|
||||
tlsFingerprint: TEST_TLS_FINGERPRINT,
|
||||
url: TEST_GATEWAY_URL,
|
||||
}));
|
||||
const formatGatewayTransportErrorJsonMock = vi.fn();
|
||||
const probeGatewayStatusMock = vi.fn();
|
||||
vi.mock("../gateway/call.js", () => ({
|
||||
callGateway: (...args: unknown[]) => callGatewayMock(...args),
|
||||
buildGatewayConnectionDetails: (...args: [unknown, ...unknown[]]) =>
|
||||
Reflect.apply(buildGatewayConnectionDetailsMock, undefined, args),
|
||||
buildGatewayProbeConnectionDetails: (...args: [unknown, ...unknown[]]) =>
|
||||
Reflect.apply(buildGatewayProbeConnectionDetailsMock, undefined, args),
|
||||
formatGatewayTransportErrorJson: (...args: unknown[]) =>
|
||||
formatGatewayTransportErrorJsonMock(...args),
|
||||
isGatewayCredentialsRequiredError: (value: unknown) =>
|
||||
isGatewayCredentialsRequiredErrorMock(value),
|
||||
}));
|
||||
|
||||
vi.mock("../cli/daemon-cli/probe.js", () => ({
|
||||
probeGatewayStatus: (...args: unknown[]) => probeGatewayStatusMock(...args),
|
||||
}));
|
||||
|
||||
vi.mock("../channels/plugins/read-only.js", () => ({
|
||||
@@ -101,9 +127,18 @@ describe("healthCommand", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
buildGatewayConnectionDetailsMock.mockReturnValue({
|
||||
message: "Gateway mode: local\nGateway target: ws://127.0.0.1:18789",
|
||||
message: TEST_GATEWAY_MESSAGE,
|
||||
url: TEST_GATEWAY_URL,
|
||||
});
|
||||
buildGatewayProbeConnectionDetailsMock.mockReturnValue({
|
||||
message: TEST_GATEWAY_MESSAGE,
|
||||
preauthHandshakeTimeoutMs: 4321,
|
||||
tlsFingerprint: TEST_TLS_FINGERPRINT,
|
||||
url: TEST_GATEWAY_URL,
|
||||
});
|
||||
formatGatewayTransportErrorJsonMock.mockReturnValue(null);
|
||||
isGatewayCredentialsRequiredErrorMock.mockReturnValue(false);
|
||||
probeGatewayStatusMock.mockReset();
|
||||
});
|
||||
|
||||
it("outputs JSON from gateway", async () => {
|
||||
@@ -186,7 +221,7 @@ describe("healthCommand", () => {
|
||||
expect(runtime.log.mock.calls.slice(0, 3)).toEqual([
|
||||
["Gateway connection:"],
|
||||
[" Gateway mode: local"],
|
||||
[" Gateway target: ws://127.0.0.1:18789"],
|
||||
[` Gateway target: ${TEST_GATEWAY_URL}`],
|
||||
]);
|
||||
expect(buildGatewayConnectionDetailsMock).toHaveBeenCalled();
|
||||
});
|
||||
@@ -229,7 +264,7 @@ describe("healthCommand", () => {
|
||||
reason: "no close reason",
|
||||
},
|
||||
gateway: {
|
||||
url: "ws://127.0.0.1:18789",
|
||||
url: TEST_GATEWAY_URL,
|
||||
urlSource: "local loopback",
|
||||
bindDetail: "Bind: loopback",
|
||||
},
|
||||
@@ -244,6 +279,47 @@ describe("healthCommand", () => {
|
||||
expect(JSON.parse(requireFirstRuntimeLog())).toEqual(payload);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ json: true, expectedLogs: 1 },
|
||||
{ json: undefined, expectedLogs: 2 },
|
||||
])(
|
||||
"reports reachable gateway diagnostics when health RPC credentials are missing",
|
||||
async ({ json, expectedLogs }) => {
|
||||
callGatewayMock.mockRejectedValueOnce(new Error());
|
||||
isGatewayCredentialsRequiredErrorMock.mockReturnValueOnce(true);
|
||||
probeGatewayStatusMock.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
kind: "connect",
|
||||
error: TEST_AUTH_CLOSE_ERROR,
|
||||
});
|
||||
|
||||
await healthCommand({ json, timeoutMs: 5000, config: {} }, runtime as never);
|
||||
|
||||
expect(probeGatewayStatusMock).toHaveBeenCalledWith({
|
||||
url: TEST_GATEWAY_URL,
|
||||
token: undefined,
|
||||
password: undefined,
|
||||
tlsFingerprint: TEST_TLS_FINGERPRINT,
|
||||
preauthHandshakeTimeoutMs: 4321,
|
||||
timeoutMs: 5000,
|
||||
config: {},
|
||||
json,
|
||||
});
|
||||
expect(runtime.exit).toHaveBeenCalledWith(1);
|
||||
expect(runtime.log).toHaveBeenCalledTimes(expectedLogs);
|
||||
if (json) {
|
||||
expect(JSON.parse(requireFirstRuntimeLog())).toEqual(
|
||||
buildCredentialsRequiredHealthDiagnostic(),
|
||||
);
|
||||
} else {
|
||||
expect(runtime.log.mock.calls).toEqual([
|
||||
[GATEWAY_HEALTH_REACHABLE_LINE],
|
||||
[GATEWAY_HEALTH_CREDENTIALS_REQUIRED_MESSAGE],
|
||||
]);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it("formats degraded model-pricing health as a warning", () => {
|
||||
const snapshot = createHealthSummary({
|
||||
channels: {},
|
||||
|
||||
@@ -13,14 +13,17 @@ import { listReadOnlyChannelPluginsForConfig } from "../channels/plugins/read-on
|
||||
import { buildChannelAccountSnapshotFromAccount } from "../channels/plugins/status.js";
|
||||
import type { ChannelPlugin } from "../channels/plugins/types.plugin.js";
|
||||
import type { ChannelAccountSnapshot } from "../channels/plugins/types.public.js";
|
||||
import { probeGatewayStatus } from "../cli/daemon-cli/probe.js";
|
||||
import { withProgress } from "../cli/progress.js";
|
||||
import { resolveStorePath } from "../config/sessions/paths.js";
|
||||
import type { OpenClawConfig } from "../config/types.openclaw.js";
|
||||
import { listContextEngineQuarantines } from "../context-engine/registry.js";
|
||||
import {
|
||||
buildGatewayConnectionDetails,
|
||||
buildGatewayProbeConnectionDetails,
|
||||
callGateway,
|
||||
formatGatewayTransportErrorJson,
|
||||
isGatewayCredentialsRequiredError,
|
||||
} from "../gateway/call.js";
|
||||
import {
|
||||
DEFAULT_CHANNEL_CONNECT_GRACE_MS,
|
||||
@@ -38,6 +41,11 @@ import { getActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { buildChannelAccountBindings, resolvePreferredAccountId } from "../routing/bindings.js";
|
||||
import { normalizeAgentId } from "../routing/session-key.js";
|
||||
import { type RuntimeEnv, writeRuntimeJson } from "../runtime.js";
|
||||
import {
|
||||
buildCredentialsRequiredHealthDiagnostic,
|
||||
GATEWAY_HEALTH_REACHABLE_LINE,
|
||||
gatewayProbeResultSawGateway,
|
||||
} from "./gateway-health-auth-diagnostic.js";
|
||||
import { formatHealthChannelLines } from "./health-format.js";
|
||||
import type {
|
||||
AgentHealthSummary,
|
||||
@@ -647,6 +655,36 @@ export async function healthCommand(
|
||||
}),
|
||||
);
|
||||
} catch (error) {
|
||||
if (isGatewayCredentialsRequiredError(error)) {
|
||||
const details = await buildGatewayProbeConnectionDetails({
|
||||
config: cfg,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
});
|
||||
const probe = await probeGatewayStatus({
|
||||
url: details.url,
|
||||
token: opts.token,
|
||||
password: opts.password,
|
||||
tlsFingerprint: details.tlsFingerprint,
|
||||
preauthHandshakeTimeoutMs: details.preauthHandshakeTimeoutMs,
|
||||
timeoutMs: opts.timeoutMs ?? DEFAULT_TIMEOUT_MS,
|
||||
config: cfg,
|
||||
json: opts.json,
|
||||
});
|
||||
if (gatewayProbeResultSawGateway(probe)) {
|
||||
const diagnostic = buildCredentialsRequiredHealthDiagnostic();
|
||||
if (opts.json) {
|
||||
writeRuntimeJson(runtime, diagnostic);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
runtime.log(GATEWAY_HEALTH_REACHABLE_LINE);
|
||||
runtime.log(diagnostic.error.message);
|
||||
runtime.exit(1);
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
if (opts.json) {
|
||||
const payload = formatGatewayTransportErrorJson(error);
|
||||
if (payload) {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -284,7 +284,7 @@ describe("config schema", () => {
|
||||
expect(progressPropsFor("discord")).not.toHaveProperty("nativeTaskCards");
|
||||
expect(progressPropsFor("telegram")).not.toHaveProperty("nativeTaskCards");
|
||||
expect(progressPropsFor("discord")).toHaveProperty("commentary");
|
||||
expect(progressPropsFor("telegram")).not.toHaveProperty("commentary");
|
||||
expect(progressPropsFor("telegram")).toHaveProperty("commentary");
|
||||
expect(res.uiHints["channels.matrix"]?.label).toBe("Matrix");
|
||||
expect(res.uiHints["channels.matrix.accessToken"]?.sensitive).toBe(true);
|
||||
expect(res.uiHints["channels.matrix.streaming.progress.label"]?.label).toBe(
|
||||
@@ -298,7 +298,9 @@ describe("config schema", () => {
|
||||
expect(res.uiHints["channels.discord.streaming.progress.toolProgress"]?.label).toBe(
|
||||
"Discord Progress Tool Lines",
|
||||
);
|
||||
expect(res.uiHints["channels.telegram.streaming.progress.commentary"]).toBeUndefined();
|
||||
expect(res.uiHints["channels.telegram.streaming.progress.commentary"]?.label).toBe(
|
||||
"Telegram Progress Commentary",
|
||||
);
|
||||
expect(res.uiHints["channels.mattermost.streaming.progress.label"]?.label).toBe(
|
||||
"Mattermost Progress Label",
|
||||
);
|
||||
@@ -456,7 +458,7 @@ describe("config schema", () => {
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("accepts progress commentary only for Discord streaming config", () => {
|
||||
it("accepts progress commentary for Discord and Telegram streaming config", () => {
|
||||
expect(
|
||||
DiscordConfigSchema.safeParse({
|
||||
streaming: {
|
||||
@@ -473,7 +475,7 @@ describe("config schema", () => {
|
||||
progress: { commentary: true },
|
||||
},
|
||||
}).success,
|
||||
).toBe(false);
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps per-agent model overrides limited to model selection", () => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import type {
|
||||
ChannelPreviewStreamingConfig,
|
||||
ChannelStreamingProgressConfig,
|
||||
ChannelStreamingPreviewConfig,
|
||||
ContextVisibilityMode,
|
||||
DmPolicy,
|
||||
@@ -76,6 +77,12 @@ export type TelegramStreamingPreviewConfig = ChannelStreamingPreviewConfig & {
|
||||
|
||||
export type TelegramPreviewStreamingConfig = Omit<ChannelPreviewStreamingConfig, "preview"> & {
|
||||
preview?: TelegramStreamingPreviewConfig;
|
||||
progress?: TelegramStreamingProgressConfig;
|
||||
};
|
||||
|
||||
export type TelegramStreamingProgressConfig = ChannelStreamingProgressConfig & {
|
||||
/** Include assistant commentary/preamble text in the progress draft. Default: false. */
|
||||
commentary?: boolean;
|
||||
};
|
||||
|
||||
export type TelegramExecApprovalConfig = {
|
||||
|
||||
@@ -103,7 +103,7 @@ const ChannelStreamingProgressSchema = z
|
||||
commandText: z.enum(["raw", "status"]).optional(),
|
||||
})
|
||||
.strict();
|
||||
const DiscordStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
|
||||
const ChannelCommentaryStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
|
||||
commentary: z.boolean().optional(),
|
||||
}).strict();
|
||||
const SlackStreamingProgressSchema = ChannelStreamingProgressSchema.extend({
|
||||
@@ -122,7 +122,7 @@ const TelegramPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema
|
||||
preview: TelegramStreamingPreviewSchema.optional(),
|
||||
}).strict();
|
||||
const DiscordPreviewStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
|
||||
progress: DiscordStreamingProgressSchema.optional(),
|
||||
progress: ChannelCommentaryStreamingProgressSchema.optional(),
|
||||
}).strict();
|
||||
const SlackStreamingConfigSchema = ChannelPreviewStreamingConfigSchema.extend({
|
||||
nativeTransport: z.boolean().optional(),
|
||||
@@ -1704,3 +1704,7 @@ export const MSTeamsConfigSchema = z
|
||||
// so we cannot require them in the config object itself.
|
||||
// Runtime validation happens in resolveMSTeamsCredentials().
|
||||
});
|
||||
|
||||
// Keep this runtime-only widening out of exported schema declarations.
|
||||
TelegramPreviewStreamingConfigSchema.shape.progress =
|
||||
ChannelCommentaryStreamingProgressSchema.optional();
|
||||
|
||||
@@ -27,12 +27,20 @@ const mocks = vi.hoisted(() => ({
|
||||
config: {},
|
||||
issues: [],
|
||||
}),
|
||||
checkGatewayHealth: vi.fn(),
|
||||
probeGatewayMemoryStatus: vi.fn(),
|
||||
applyWizardMetadata: vi.fn((cfg: unknown) => cfg),
|
||||
logConfigUpdated: vi.fn(),
|
||||
isRecord: vi.fn(
|
||||
(value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value),
|
||||
),
|
||||
shortenHomePath: vi.fn((p: string) => p),
|
||||
formatCliCommand: vi.fn((cmd: string) => cmd),
|
||||
}));
|
||||
|
||||
const DOCTOR_GATEWAY_HEALTH_ID = "doctor:gateway-health";
|
||||
|
||||
vi.mock("../commands/doctor/shared/release-configured-plugin-installs.js", () => ({
|
||||
maybeRunConfiguredPluginInstallReleaseStep: mocks.maybeRunConfiguredPluginInstallReleaseStep,
|
||||
}));
|
||||
@@ -83,6 +91,11 @@ vi.mock("../config/config.js", () => ({
|
||||
readConfigFileSnapshot: mocks.readConfigFileSnapshot,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/doctor-gateway-health.js", () => ({
|
||||
checkGatewayHealth: mocks.checkGatewayHealth,
|
||||
probeGatewayMemoryStatus: mocks.probeGatewayMemoryStatus,
|
||||
}));
|
||||
|
||||
vi.mock("../commands/onboard-helpers.js", () => ({
|
||||
applyWizardMetadata: mocks.applyWizardMetadata,
|
||||
}));
|
||||
@@ -92,6 +105,7 @@ vi.mock("../config/logging.js", () => ({
|
||||
}));
|
||||
|
||||
vi.mock("../utils.js", () => ({
|
||||
isRecord: mocks.isRecord,
|
||||
shortenHomePath: mocks.shortenHomePath,
|
||||
}));
|
||||
|
||||
@@ -176,6 +190,8 @@ describe("doctor health contributions", () => {
|
||||
config: {},
|
||||
issues: [],
|
||||
});
|
||||
mocks.checkGatewayHealth.mockReset();
|
||||
mocks.probeGatewayMemoryStatus.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -193,6 +209,32 @@ describe("doctor health contributions", () => {
|
||||
expect(ids.indexOf("doctor:plugin-registry")).toBeLessThan(ids.indexOf("doctor:write-config"));
|
||||
});
|
||||
|
||||
it("skips read-scope gateway probes when gateway health only proved reachability", async () => {
|
||||
mocks.checkGatewayHealth.mockResolvedValue({
|
||||
authenticated: false,
|
||||
healthOk: true,
|
||||
});
|
||||
const contribution = requireDoctorContribution(DOCTOR_GATEWAY_HEALTH_ID);
|
||||
const ctx = {
|
||||
cfg: {},
|
||||
configResult: { cfg: {} },
|
||||
sourceConfigValid: true,
|
||||
prompter: buildDoctorPrompter(false),
|
||||
runtime: { log: vi.fn(), error: vi.fn(), exit: vi.fn() },
|
||||
options: {},
|
||||
cfgForPersistence: {},
|
||||
configPath: "/tmp/fake-openclaw.json",
|
||||
env: {},
|
||||
} as Parameters<(typeof contribution)["run"]>[0];
|
||||
|
||||
await contribution.run(ctx);
|
||||
|
||||
expect(ctx.healthOk).toBe(true);
|
||||
expect(ctx.gatewayHealthAuthenticated).toBe(false);
|
||||
expect(ctx.gatewayMemoryProbe).toEqual({ checked: false, ready: false, skipped: true });
|
||||
expect(mocks.probeGatewayMemoryStatus).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps release configured plugin installs repair-only", async () => {
|
||||
const contribution = requireDoctorContribution("doctor:release-configured-plugin-installs");
|
||||
const ctx = {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user