mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
202 Commits
v2026.5.31
...
fix-exec-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c642e1da24 | ||
|
|
6316648bab | ||
|
|
bf777b9af2 | ||
|
|
fba9eac7eb | ||
|
|
5965522af5 | ||
|
|
f18fd2094f | ||
|
|
770ee8eba6 | ||
|
|
b891d42f3a | ||
|
|
705bdcec70 | ||
|
|
db7aff8843 | ||
|
|
d30329fb0e | ||
|
|
c7f3d60722 | ||
|
|
0ffaeb1273 | ||
|
|
c43a571170 | ||
|
|
dd8b9bdcb8 | ||
|
|
399f55e511 | ||
|
|
7e654b40b8 | ||
|
|
7b119ec60d | ||
|
|
c1fffe1074 | ||
|
|
530f3aaab7 | ||
|
|
3ec1a25de4 | ||
|
|
5a6ec67eb0 | ||
|
|
0fdca6974d | ||
|
|
dc344a33fb | ||
|
|
e4a766f2f4 | ||
|
|
ad07ba141d | ||
|
|
bd78737f94 | ||
|
|
5f6e608c60 | ||
|
|
ddbd16a04a | ||
|
|
03151a2ebe | ||
|
|
1b69e7a005 | ||
|
|
227530f906 | ||
|
|
6df3fd5730 | ||
|
|
7c315252d6 | ||
|
|
0d7abcc94f | ||
|
|
344773ba09 | ||
|
|
ae4550f48b | ||
|
|
fdd02444b7 | ||
|
|
3491834d49 | ||
|
|
12cf34a8ea | ||
|
|
d328a0d7a0 | ||
|
|
421ad93203 | ||
|
|
dc05f598bb | ||
|
|
3171278372 | ||
|
|
01193dea26 | ||
|
|
cb9847968a | ||
|
|
54987715f3 | ||
|
|
0c74f18a1c | ||
|
|
59122812c0 | ||
|
|
bc95af1b7c | ||
|
|
144405e562 | ||
|
|
290b19275b | ||
|
|
72f74b33e1 | ||
|
|
bb673f47b2 | ||
|
|
16ef9c1435 | ||
|
|
2b30951b80 | ||
|
|
56b8030cd9 | ||
|
|
5706619068 | ||
|
|
edc0a22179 | ||
|
|
2682c02774 | ||
|
|
59683978e1 | ||
|
|
c8f8907f15 | ||
|
|
8eb1838dfa | ||
|
|
01f6ad6056 | ||
|
|
b7f657b3b0 | ||
|
|
22cb7fb6b7 | ||
|
|
48afba96a3 | ||
|
|
470a1ae8d1 | ||
|
|
a2acfc5049 | ||
|
|
fe8c781d67 | ||
|
|
ac2484f23e | ||
|
|
cabfbdfe0d | ||
|
|
5e2472567a | ||
|
|
79c4ac73d7 | ||
|
|
2a1882ebcc | ||
|
|
3bb04b67e9 | ||
|
|
cd0a7b10e2 | ||
|
|
bc45c36dbc | ||
|
|
7184522fae | ||
|
|
aa74d93aff | ||
|
|
be0d3489a6 | ||
|
|
f06b4b9aab | ||
|
|
0700f13d62 | ||
|
|
3c6c247e0a | ||
|
|
2e42b1372e | ||
|
|
f78bb34cb4 | ||
|
|
85c7490f72 | ||
|
|
63d93db867 | ||
|
|
2976db4b2c | ||
|
|
025bb01268 | ||
|
|
7a292bb16e | ||
|
|
a9e3eade5d | ||
|
|
3733cd8d63 | ||
|
|
190f935b53 | ||
|
|
c21e16c73d | ||
|
|
d52f1ea5ec | ||
|
|
13967e17e6 | ||
|
|
7ad2aa44dd | ||
|
|
874b3f921e | ||
|
|
c11d5d6d65 | ||
|
|
11631bf044 | ||
|
|
561e993282 | ||
|
|
23bf48e69e | ||
|
|
7d65ea3513 | ||
|
|
bfac12a184 | ||
|
|
cdcc151145 | ||
|
|
7681b95199 | ||
|
|
caa08a6dc0 | ||
|
|
4339d7c1d8 | ||
|
|
aa187c6496 | ||
|
|
34010894c1 | ||
|
|
c74bb4475a | ||
|
|
299a023bd1 | ||
|
|
0c852036c7 | ||
|
|
9cc759dd37 | ||
|
|
d1378650bb | ||
|
|
40f99e474a | ||
|
|
dc71b5867e | ||
|
|
fd2c65f59b | ||
|
|
575f74293e | ||
|
|
b27ae3f6e7 | ||
|
|
b388d3dc71 | ||
|
|
01b7ef9e88 | ||
|
|
4b89def277 | ||
|
|
fabd9469cd | ||
|
|
d3025b4007 | ||
|
|
c06096eabc | ||
|
|
9577e0be5a | ||
|
|
b12724b79b | ||
|
|
0de60cec12 | ||
|
|
c6232347dc | ||
|
|
b73e135f97 | ||
|
|
9b6c981260 | ||
|
|
02ac0ec48b | ||
|
|
d8329dedf6 | ||
|
|
b86e8bf359 | ||
|
|
3bb9224836 | ||
|
|
fdc10a64e9 | ||
|
|
87174c80b6 | ||
|
|
97c040f946 | ||
|
|
f833e96a31 | ||
|
|
9a32c0f85d | ||
|
|
d306f5bf2e | ||
|
|
65d5f7436c | ||
|
|
b78ce079a3 | ||
|
|
6c6cf41b14 | ||
|
|
0d79cbab4e | ||
|
|
b04c3e96d6 | ||
|
|
3854a61bea | ||
|
|
0d07e30725 | ||
|
|
bfc151e9d3 | ||
|
|
b653d94918 | ||
|
|
49e5091f18 | ||
|
|
cbdb59b255 | ||
|
|
2ac2a8d210 | ||
|
|
d042452d20 | ||
|
|
50f27ee91d | ||
|
|
84266cd30e | ||
|
|
61e9961abb | ||
|
|
7c04ce3a79 | ||
|
|
2ff9e27d4e | ||
|
|
5ee3e5d8c0 | ||
|
|
03dec8bb3a | ||
|
|
5bc80dbe27 | ||
|
|
8383e2e4d9 | ||
|
|
7f93755206 | ||
|
|
7dd1bd894b | ||
|
|
6ed6120977 | ||
|
|
0f396368a9 | ||
|
|
72679b16eb | ||
|
|
4a09fd43e2 | ||
|
|
026ab6b882 | ||
|
|
730492867f | ||
|
|
ceda284845 | ||
|
|
8da6b67607 | ||
|
|
e0d3c78042 | ||
|
|
af7749123b | ||
|
|
9d97e683d4 | ||
|
|
e2c745fc58 | ||
|
|
5df0ed3b9f | ||
|
|
e5acae4453 | ||
|
|
8076eead77 | ||
|
|
f6365d07c4 | ||
|
|
9a3e7d4f51 | ||
|
|
ce1165afda | ||
|
|
90712f6d5e | ||
|
|
7c15c2765e | ||
|
|
e681569536 | ||
|
|
b0679d1f13 | ||
|
|
80b7f56603 | ||
|
|
995a9bd702 | ||
|
|
92b9cd21ec | ||
|
|
d62bfab946 | ||
|
|
7aa309319f | ||
|
|
2df95c0b10 | ||
|
|
6f58a71582 | ||
|
|
55fc3c10b0 | ||
|
|
b4a6244ef4 | ||
|
|
6b2cb4db67 | ||
|
|
0715081990 | ||
|
|
462b52f62c | ||
|
|
118b9cacf6 |
@@ -4,11 +4,11 @@ profile: openclaw-check
|
||||
provider: azure
|
||||
class: standard
|
||||
capacity:
|
||||
market: spot
|
||||
market: on-demand
|
||||
strategy: most-available
|
||||
# Fail closed instead of silently falling back to on-demand while the
|
||||
# Azure-backed billing account is the default runner path.
|
||||
fallback: spot-only
|
||||
# The Azure-backed billing account carries the OpenClaw runner credits; use
|
||||
# explicit on-demand capacity instead of low-priority spot, whose regional
|
||||
# quota is too small for broad maintainer proof or parallel Crabbox lanes.
|
||||
hints: true
|
||||
actions:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
@@ -48,6 +48,10 @@ aws:
|
||||
# leaking AWS region names into the Azure default capacity fallback list.
|
||||
region: eu-west-1
|
||||
rootGB: 400
|
||||
azure:
|
||||
# The OpenClaw Azure subscription is reliable in eastus2; eastus rejects the
|
||||
# same SKUs and can stall provisioning.
|
||||
location: eastus2
|
||||
sync:
|
||||
delete: true
|
||||
checksum: false
|
||||
@@ -67,13 +71,16 @@ env:
|
||||
- OPENCLAW_*
|
||||
ssh:
|
||||
user: crabbox
|
||||
port: "2222"
|
||||
# Azure coordinator leases expose SSH on 22. The run wrapper can fall back
|
||||
# from 2222, but `crabbox job run` hydrates via the configured port directly.
|
||||
port: "22"
|
||||
jobs:
|
||||
prewarm:
|
||||
provider: azure
|
||||
target: linux
|
||||
class: standard
|
||||
market: spot
|
||||
type: Standard_D4ads_v6
|
||||
market: on-demand
|
||||
idleTimeout: 90m
|
||||
hydrate:
|
||||
actions: true
|
||||
@@ -90,7 +97,8 @@ jobs:
|
||||
provider: azure
|
||||
target: linux
|
||||
class: standard
|
||||
market: spot
|
||||
type: Standard_D4ads_v6
|
||||
market: on-demand
|
||||
idleTimeout: 90m
|
||||
hydrate:
|
||||
actions: true
|
||||
@@ -99,7 +107,18 @@ jobs:
|
||||
workflow: .github/workflows/crabbox-hydrate.yml
|
||||
job: hydrate
|
||||
ref: main
|
||||
command: env OPENCLAW_CHECK_CHANGED_REMOTE_CHILD=1 OPENCLAW_CHANGED_LANES_RAW_SYNC=1 CI=1 corepack pnpm check:changed
|
||||
shell: true
|
||||
command: |
|
||||
set -euo pipefail
|
||||
if ! git status --short >/dev/null 2>&1; then
|
||||
rm -rf .git
|
||||
git init -q
|
||||
git add -A
|
||||
if ! git diff --cached --quiet; then
|
||||
git -c user.name=OpenClaw -c user.email=ci@openclaw.local commit -q --no-gpg-sign -m remote-check-tree
|
||||
fi
|
||||
fi
|
||||
env CI=1 corepack pnpm check --timed
|
||||
stop: always
|
||||
testbox-changed:
|
||||
provider: blacksmith-testbox
|
||||
|
||||
136
.github/workflows/ci-check-testbox.yml
vendored
136
.github/workflows/ci-check-testbox.yml
vendored
@@ -139,3 +139,139 @@ 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"
|
||||
|
||||
7
.github/workflows/ci.yml
vendored
7
.github/workflows/ci.yml
vendored
@@ -1202,6 +1202,9 @@ jobs:
|
||||
- check_name: check-guards
|
||||
task: guards
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-shrinkwrap
|
||||
task: shrinkwrap
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
- check_name: check-prod-types
|
||||
task: prod-types
|
||||
runner: blacksmith-4vcpu-ubuntu-2404
|
||||
@@ -1277,7 +1280,6 @@ jobs:
|
||||
pnpm tool-display:check
|
||||
pnpm check:host-env-policy:swift
|
||||
pnpm dup:check:coverage
|
||||
pnpm deps:shrinkwrap:check
|
||||
pnpm deps:patches:check
|
||||
pnpm lint:webhook:no-low-level-body-read
|
||||
pnpm lint:auth:no-pairing-store-group
|
||||
@@ -1286,6 +1288,9 @@ jobs:
|
||||
# build-artifacts already runs the tsdown/runtime build for the same Node-relevant changes.
|
||||
NODE_OPTIONS=--max-old-space-size=8192 pnpm build:plugin-sdk:strict-smoke
|
||||
;;
|
||||
shrinkwrap)
|
||||
pnpm deps:shrinkwrap:check
|
||||
;;
|
||||
prod-types)
|
||||
pnpm tsgo:prod
|
||||
;;
|
||||
|
||||
2
.github/workflows/update-migration.yml
vendored
2
.github/workflows/update-migration.yml
vendored
@@ -43,4 +43,4 @@ jobs:
|
||||
published_upgrade_survivor_baselines: ${{ inputs.baselines }}
|
||||
published_upgrade_survivor_scenarios: ${{ inputs.scenarios }}
|
||||
telegram_mode: none
|
||||
secrets: inherit
|
||||
secrets: inherit # zizmor: ignore[secrets-inherit] Maintainer-dispatched package acceptance lane intentionally forwards its declared live-test secret matrix.
|
||||
|
||||
4
.github/workflows/windows-testbox-probe.yml
vendored
4
.github/workflows/windows-testbox-probe.yml
vendored
@@ -61,12 +61,14 @@ jobs:
|
||||
submodules: false
|
||||
|
||||
- name: Probe native Windows
|
||||
env:
|
||||
TARGET_REF: ${{ inputs.target_ref || github.ref }}
|
||||
run: |
|
||||
$ErrorActionPreference = "Stop"
|
||||
Write-Host "runner=$env:RUNNER_NAME"
|
||||
Write-Host "machine=$env:COMPUTERNAME"
|
||||
Write-Host "workspace=$env:GITHUB_WORKSPACE"
|
||||
Write-Host "target_ref=${{ inputs.target_ref || github.ref }}"
|
||||
Write-Host "target_ref=$env:TARGET_REF"
|
||||
Write-Host ("os=" + [System.Environment]::OSVersion.VersionString)
|
||||
Write-Host ("arch=" + [System.Runtime.InteropServices.RuntimeInformation]::OSArchitecture)
|
||||
Write-Host ("powershell=" + $PSVersionTable.PSVersion.ToString())
|
||||
|
||||
68
.github/workflows/workflow-sanity.yml
vendored
68
.github/workflows/workflow-sanity.yml
vendored
@@ -84,6 +84,65 @@ jobs:
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout"
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Setup Python
|
||||
uses: actions/setup-python@v6
|
||||
with:
|
||||
python-version: "3.12"
|
||||
|
||||
- name: Prepare trusted workflow audit configs
|
||||
if: github.event_name == 'pull_request'
|
||||
env:
|
||||
BASE_REF: ${{ github.event.pull_request.base.ref }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
trusted_config="$RUNNER_TEMP/pre-commit-base.yaml"
|
||||
trusted_zizmor_config="$RUNNER_TEMP/zizmor-base.yml"
|
||||
|
||||
if ! git cat-file -e "${BASE_SHA}^{commit}" 2>/dev/null; then
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+${BASE_SHA}:refs/remotes/origin/security-base" ||
|
||||
timeout --signal=TERM --kill-after=10s 30s git fetch --no-tags --depth=1 origin \
|
||||
"+refs/heads/${BASE_REF}:refs/remotes/origin/${BASE_REF}"
|
||||
fi
|
||||
|
||||
if git cat-file -e "${BASE_SHA}:.pre-commit-config.yaml" 2>/dev/null; then
|
||||
git show "${BASE_SHA}:.pre-commit-config.yaml" > "$trusted_config"
|
||||
elif git show "refs/remotes/origin/${BASE_REF}:.pre-commit-config.yaml" \
|
||||
> "$trusted_config" 2>/dev/null; then
|
||||
echo "Base SHA ${BASE_SHA} does not expose .pre-commit-config.yaml; using origin/${BASE_REF} instead."
|
||||
else
|
||||
echo "::error title=trusted pre-commit config unavailable::Could not read .pre-commit-config.yaml from ${BASE_SHA} or origin/${BASE_REF}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if git cat-file -e "${BASE_SHA}:.github/zizmor.yml" 2>/dev/null; then
|
||||
git show "${BASE_SHA}:.github/zizmor.yml" > "$trusted_zizmor_config"
|
||||
elif git show "refs/remotes/origin/${BASE_REF}:.github/zizmor.yml" \
|
||||
> "$trusted_zizmor_config" 2>/dev/null; then
|
||||
echo "Base SHA ${BASE_SHA} does not expose .github/zizmor.yml; using origin/${BASE_REF} instead."
|
||||
else
|
||||
echo "::error title=trusted zizmor config unavailable::Could not read .github/zizmor.yml from ${BASE_SHA} or origin/${BASE_REF}."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
python3 - "$trusted_config" "$trusted_zizmor_config" <<'PY'
|
||||
from pathlib import Path
|
||||
import sys
|
||||
|
||||
config_path = Path(sys.argv[1])
|
||||
zizmor_config_path = sys.argv[2]
|
||||
text = config_path.read_text()
|
||||
if ".github/zizmor.yml" not in text:
|
||||
raise SystemExit("trusted pre-commit config does not reference .github/zizmor.yml")
|
||||
config_path.write_text(text.replace(".github/zizmor.yml", zizmor_config_path))
|
||||
PY
|
||||
|
||||
echo "PRE_COMMIT_CONFIG_PATH=$trusted_config" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Install pre-commit
|
||||
run: python -m pip install --disable-pip-version-check pre-commit==4.2.0
|
||||
|
||||
- name: Install actionlint
|
||||
shell: bash
|
||||
run: |
|
||||
@@ -103,6 +162,15 @@ jobs:
|
||||
- name: Lint workflows
|
||||
run: actionlint
|
||||
|
||||
- name: Audit all workflows with zizmor
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
mapfile -t workflow_files < <(
|
||||
find .github/workflows -maxdepth 1 -type f \( -name '*.yml' -o -name '*.yaml' \) | sort
|
||||
)
|
||||
pre-commit run --config "${PRE_COMMIT_CONFIG_PATH:-.pre-commit-config.yaml}" zizmor --files "${workflow_files[@]}"
|
||||
|
||||
- name: Disallow direct inputs interpolation in composite run blocks
|
||||
run: python3 scripts/check-composite-action-input-interpolation.py
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
"eslint/no-object-constructor": "error",
|
||||
"eslint/no-param-reassign": "error",
|
||||
"eslint/no-proto": "error",
|
||||
"eslint/no-promise-executor-return": "error",
|
||||
"eslint/no-regex-spaces": "error",
|
||||
"eslint/no-return-assign": "error",
|
||||
"eslint/no-sequences": "error",
|
||||
@@ -35,6 +36,7 @@
|
||||
"eslint/no-useless-constructor": "error",
|
||||
"eslint/no-useless-rename": "error",
|
||||
"eslint/no-useless-return": "error",
|
||||
"eslint/no-useless-assignment": "error",
|
||||
"eslint/no-unused-vars": "error",
|
||||
"eslint/no-warning-comments": "error",
|
||||
"eslint/no-unmodified-loop-condition": "error",
|
||||
@@ -78,6 +80,7 @@
|
||||
"typescript/no-extraneous-class": "error",
|
||||
"typescript/no-import-type-side-effects": "error",
|
||||
"typescript/no-meaningless-void-operator": "error",
|
||||
"typescript/no-misused-promises": "error",
|
||||
"typescript/no-inferrable-types": "error",
|
||||
"typescript/no-non-null-asserted-nullish-coalescing": "error",
|
||||
"typescript/no-unnecessary-qualifier": "error",
|
||||
|
||||
@@ -74,6 +74,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Core runtime consumes only current canonical shapes/config/data. Legacy or retired shapes normalize only in doctor/migration code before runtime; no runtime shims, aliases, or fallback readers.
|
||||
- State/storage migrations are database-first. Runtime reads/writes the canonical store only. Old file stores, sidecars, aliases, and fallback readers belong in `openclaw doctor --fix` migration code only, never steady-state runtime.
|
||||
- Storage default: SQLite only. Do not add JSON/JSONL/TXT/sidecar files for OpenClaw-owned runtime state, caches, queues, registries, indexes, cursors, checkpoints, or plugin scratch data.
|
||||
- SQLite runtime access uses Kysely helpers, not raw SQL statement strings, except schema DDL, migrations, low-level DB bootstrap, or narrowly justified SQLite primitives.
|
||||
- Use the shared state DB (`state/openclaw.sqlite`) for global runtime state and plugin KV data. Use the per-agent DB (`agents/<agentId>/agent/openclaw-agent.sqlite`) for agent-scoped state/cache. Use a dedicated SQLite DB only when schema, volume, or lifecycle clearly does not fit those stores.
|
||||
- Legacy state/cache files are migration debt. When touching code that reads/writes them, prefer moving the data into SQLite or calling out the refactor follow-up; do not add parallel file paths.
|
||||
- File storage must be a named product artifact: import/export, user attachment, log, backup, or external tool contract. If it is app state or cache, it belongs in SQLite.
|
||||
@@ -127,6 +128,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
|
||||
- Use `$openclaw-testing` for test/CI choice and `$crabbox` for remote/full/E2E proof.
|
||||
- Crabbox request means real scenario proof: install/update/call/repro user path; not just copy tests and run them remotely.
|
||||
- Visual proof: use Crabbox, set up like a user, then screenshot-verify. No harness/bypass/shortcut unless explicitly asked.
|
||||
- Small/narrow tests, lints, format checks, and type probes are fine locally only in a healthy normal checkout.
|
||||
- In Codex worktrees, direct local `pnpm test*`, `pnpm check*`, `pnpm crabbox:run`, and `scripts/committer` can trigger pnpm dependency reconciliation or install prompts. Prefer `node` wrappers locally and Crabbox/Testbox for pnpm-gated proof.
|
||||
- Full suites, broad changed gates, Docker/package/E2E/live/cross-OS proof, or anything that bogs down the Mac: Crabbox/Testbox.
|
||||
@@ -263,7 +265,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Version bump surfaces live in `$release-openclaw-maintainer`.
|
||||
- Parallels: `$openclaw-parallels-smoke`; Discord roundtrip: `$parallels-discord-roundtrip`.
|
||||
- Crabbox/WebVNC human demos: keep remote desktop visible/windowed; no fullscreen remote browser unless video/capture-style output.
|
||||
- Before sharing WebVNC links, capture screenshot and verify target UI is not broken.
|
||||
- Before sharing WebVNC links, use Crabbox screenshot first; verify real app/path works and target UI is not broken.
|
||||
- ClawSweeper ops: `$clawsweeper`. Deployed hook sessions may post one concise `#clawsweeper` note only when surprising/actionable/risky; if using message tool, reply exactly `NO_REPLY`.
|
||||
- Generated-media completions wake the requester agent first. Requester visible-reply config decides final text vs message tool; direct media send is fallback/recovery only.
|
||||
- `message_tool_only`: normal agent final visible reply = current-source `message(action=send)` only. No `NO_REPLY` prompt/contract; no message call = no source reply. Plugin-owned bound-thread reply = plugin return value; no message tool needed. Never auto-publish private final.
|
||||
|
||||
@@ -57,7 +57,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Docs/CI: run Mintlify anchor checks through the repo pnpm runner so docs link validation works when pnpm is only available through the hydrated package-manager shim.
|
||||
- Agents: keep configured fallback model metadata typed so provider params, context-token caps, and media input limits do not break changed-gate typechecks.
|
||||
- Agents: accept hidden `sessions_send` body aliases before validation while keeping the model-facing `message` schema canonical. (#88229) Thanks @zhangguiping-xydt.
|
||||
- CI/Crabbox: keep default runner capacity spot-only and provider-neutral so OpenClaw remote validation does not silently fall back to on-demand leases or stale AWS region hints.
|
||||
- CI/Crabbox: keep default runner capacity on the Azure credit-backed on-demand D4 lane with the Azure SSH port and a Git-independent full check job, so broad validation avoids low-priority spot quota stalls, hydrate port mismatches, non-Git hydrated workspaces, and stale AWS region hints.
|
||||
- CI/Crabbox: route Crabbox wrapper and Testbox workflow edits to their regression tests so changed-test gates do not silently run zero specs.
|
||||
- CI/workflows: route workflow sanity helper edits to their guard tests and cover composite-action input interpolation checks.
|
||||
- CI/tooling: route CI scope, dependency, changelog, and docs helper edits to their owner tests instead of silently skipping changed-test coverage.
|
||||
|
||||
@@ -5528,6 +5528,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
|
||||
public let createdat: String
|
||||
public let updatedat: String
|
||||
public let createdby: AnyCodable
|
||||
public let origin: [String: AnyCodable]?
|
||||
public let proposedversion: String
|
||||
public let draftfile: String
|
||||
public let drafthash: String
|
||||
@@ -5552,6 +5553,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
|
||||
createdat: String,
|
||||
updatedat: String,
|
||||
createdby: AnyCodable,
|
||||
origin: [String: AnyCodable]?,
|
||||
proposedversion: String,
|
||||
draftfile: String,
|
||||
drafthash: String,
|
||||
@@ -5575,6 +5577,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
|
||||
self.createdat = createdat
|
||||
self.updatedat = updatedat
|
||||
self.createdby = createdby
|
||||
self.origin = origin
|
||||
self.proposedversion = proposedversion
|
||||
self.draftfile = draftfile
|
||||
self.drafthash = drafthash
|
||||
@@ -5600,6 +5603,7 @@ public struct SkillsProposalRecordResult: Codable, Sendable {
|
||||
case createdat = "createdAt"
|
||||
case updatedat = "updatedAt"
|
||||
case createdby = "createdBy"
|
||||
case origin
|
||||
case proposedversion = "proposedVersion"
|
||||
case draftfile = "draftFile"
|
||||
case drafthash = "draftHash"
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
eadfc9b897a05664735f8e2abcb70cb3f33c19427c20802bf8b035520b7a2ea1 plugin-sdk-api-baseline.json
|
||||
8e10e093068d73b9ac50d3f265bf7d892652b0392c677be4e332248499cf7ed0 plugin-sdk-api-baseline.jsonl
|
||||
19bdf1196ec771a00777a16fd1e9c3662b8fd788a81034e705c41a74ee79c7ec plugin-sdk-api-baseline.json
|
||||
43feff80c90adad0f821d1f1e184a9bff1e93d81e6d53a26a26fd9e2972be759 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -248,7 +248,7 @@ iMessage catchup is now available as an opt-in feature on the bundled plugin. On
|
||||
|
||||
There is no supported BlueBubbles runtime to switch back to. If iMessage verification fails, set `channels.imessage.enabled: false`, restart the Gateway, fix the `imsg` blocker, and retry the cutover.
|
||||
|
||||
The reply cache lives at `~/.openclaw/state/imessage/reply-cache.jsonl` (mode `0600`, parent dir `0700`). It is safe to delete if you want a clean slate.
|
||||
The reply cache lives in SQLite plugin state. `openclaw doctor --fix` imports and archives the old `imessage/reply-cache.jsonl` sidecar when present.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -533,7 +533,7 @@ When `imsg launch` is running and `openclaw channels status --probe` reports `pr
|
||||
</Accordion>
|
||||
|
||||
<Accordion title="Message IDs">
|
||||
Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent in-memory reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`.
|
||||
Inbound iMessage context includes both short `MessageSid` values and full message GUIDs when available. Short IDs are scoped to the recent SQLite-backed reply cache and are checked against the current chat before use. If a short ID has expired or belongs to another chat, retry with the full `MessageSidFull`.
|
||||
|
||||
</Accordion>
|
||||
|
||||
@@ -714,7 +714,7 @@ Each replayed row is fed through the live dispatch path (`evaluateIMessageInboun
|
||||
|
||||
### Cursor and retry semantics
|
||||
|
||||
Catchup keeps a per-account cursor at `<openclawStateDir>/imessage/catchup/<account>__<hash>.json` (the OpenClaw state dir defaults to `~/.openclaw`, overridable with `OPENCLAW_STATE_DIR`):
|
||||
Catchup keeps a per-account cursor in SQLite plugin state:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -729,6 +729,7 @@ Catchup keeps a per-account cursor at `<openclawStateDir>/imessage/catchup/<acco
|
||||
- After the startup catchup query succeeds, later live-handled rows also advance the same cursor so a gateway restart does not replay messages that were already handled live. Live cursor writes do not jump past catchup failures that are still below `maxFailureRetries`.
|
||||
- After `maxFailureRetries` consecutive throws against the same `guid`, catchup logs a `warn` and force-advances the cursor past the wedged message so subsequent startups can make progress.
|
||||
- Already-given-up guids are skipped on sight (no dispatch attempt) on later runs and counted under `skippedGivenUp` in the run summary.
|
||||
- `openclaw doctor --fix` imports legacy `<openclawStateDir>/imessage/catchup/*.json` cursor files into SQLite plugin state and archives the old files.
|
||||
|
||||
### Operator-visible signals
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ OpenClaw CI runs on every push to `main` and every pull request. The `preflight`
|
||||
| Job | Purpose | When it runs |
|
||||
| ---------------------------------- | --------------------------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| `preflight` | Detect docs-only changes, changed scopes, changed extensions, and build the CI manifest | Always on non-draft pushes and PRs |
|
||||
| `security-fast` | Private key detection, workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
|
||||
| `security-fast` | Private key detection, changed-workflow audit via `zizmor`, and production lockfile audit | Always on non-draft pushes and PRs |
|
||||
| `check-dependencies` | Production Knip dependency-only pass plus the unused-file allowlist guard | Node-relevant changes |
|
||||
| `build-artifacts` | Build `dist/`, Control UI, built-CLI smoke checks, embedded built-artifact checks, and reusable artifacts | Node-relevant changes |
|
||||
| `checks-fast-core` | Fast Linux correctness lanes such as bundled, protocol, and CI-routing checks | Node-relevant changes |
|
||||
@@ -80,6 +80,7 @@ apply to that PR.
|
||||
Scope logic lives in `scripts/ci-changed-scope.mjs` and is covered by unit tests in `src/scripts/ci-changed-scope.test.ts`. Manual dispatch skips changed-scope detection and makes the preflight manifest act as if every scoped area changed.
|
||||
|
||||
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, Android, or macOS native builds by themselves; those platform lanes stay scoped to platform source changes.
|
||||
- **Workflow Sanity** runs `actionlint`, `zizmor` over all workflow YAML files, the composite-action interpolation guard, and the conflict-marker guard. The PR-scoped `security-fast` job also runs `zizmor` over changed workflow files so workflow security findings fail early in the main CI graph.
|
||||
- **Docs on `main` pushes** are checked by the standalone `Docs` workflow with the same ClawHub docs mirror used by CI, so mixed code+docs pushes do not also queue the CI `check-docs` shard. Pull requests and manual CI still run `check-docs` from CI when docs changed.
|
||||
- **TUI PTY** is a focused workflow for TUI changes. It runs `node scripts/run-vitest.mjs run --config test/vitest/vitest.tui-pty.config.ts` on Linux Node 24 for `src/tui/**`, the watch harness, package script, lockfile, and workflow edits. The required lane uses a deterministic `TuiBackend` fixture; the slower `tui --local` smoke is opt-in with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1` and mocks only the external model endpoint.
|
||||
- **CI routing-only edits, selected cheap core-test fixture edits, and narrow plugin contract helper/test-routing edits** use a fast Node-only manifest path: `preflight`, security, and a single `checks-fast-core` task. That path skips build artifacts, Node 22 compatibility, channel contracts, full core shards, bundled-plugin shards, and additional guard matrices when the change is limited to the routing or helper surfaces the fast task exercises directly.
|
||||
|
||||
@@ -45,6 +45,8 @@ openclaw doctor --deep
|
||||
openclaw doctor --fix
|
||||
openclaw doctor --fix --non-interactive
|
||||
openclaw doctor --generate-gateway-token
|
||||
openclaw doctor --post-upgrade
|
||||
openclaw doctor --post-upgrade --json
|
||||
```
|
||||
|
||||
For channel-specific permissions, use the channel probes instead of `doctor`:
|
||||
@@ -68,7 +70,8 @@ The targeted Discord capabilities probe reports the bot's effective channel perm
|
||||
- `--allow-exec`: allow doctor to execute configured exec SecretRefs while verifying secrets
|
||||
- `--deep`: scan system services for extra gateway installs and report recent Gateway supervisor restart handoffs
|
||||
- `--lint`: run modernized health checks in read-only mode and emit diagnostic findings
|
||||
- `--json`: with `--lint`, emit JSON findings instead of human output
|
||||
- `--post-upgrade`: run post-upgrade plugin compatibility probes; emits findings to stdout; exits with code 1 if any error-level findings are present
|
||||
- `--json`: with `--lint`, emit JSON findings instead of human output; with `--post-upgrade`, emit a machine-readable JSON envelope (`{ probesRun, findings }`)
|
||||
- `--severity-min <level>`: with `--lint`, drop findings below `info`, `warning`, or `error`
|
||||
- `--skip <id>`: with `--lint`, skip a check id; repeat to skip more than one
|
||||
- `--only <id>`: with `--lint`, run only a check id; repeat to run a small selected set
|
||||
@@ -188,6 +191,16 @@ id is not registered, no check runs for that id; use the command's `checksRun`
|
||||
and `checksSkipped` fields to verify a focused gate is selecting the checks you
|
||||
expect.
|
||||
|
||||
## Post-upgrade mode
|
||||
|
||||
`openclaw doctor --post-upgrade` runs plugin compatibility probes intended to be
|
||||
chained after a build or upgrade. Findings are emitted to stdout; the command
|
||||
exits with code 1 if any finding has `level: "error"`. Add `--json` to receive a
|
||||
machine-readable envelope (`{ probesRun, findings }`) suitable for CI, the
|
||||
community `fork-upgrade` skill, and other post-upgrade smoke tooling. If the
|
||||
installed plugin index is missing or malformed, JSON mode still emits that
|
||||
envelope with a `plugin.index_unavailable` error finding.
|
||||
|
||||
Notes:
|
||||
|
||||
- In Nix mode (`OPENCLAW_NIX_MODE=1`), read-only doctor checks still work, but `doctor --fix`, `doctor --repair`, `doctor --yes`, and `doctor --generate-gateway-token` are disabled because `openclaw.json` is immutable. Edit the Nix source for this install instead; for nix-openclaw, use the agent-first [Quick Start](https://github.com/openclaw/nix-openclaw#quick-start).
|
||||
|
||||
@@ -91,7 +91,7 @@ For the bundled non-ACP Codex harness, OpenClaw applies the same lifecycle by pr
|
||||
OpenClaw calls two optional subagent lifecycle hooks:
|
||||
|
||||
<ParamField path="prepareSubagentSpawn" type="method">
|
||||
Prepare shared context state before a child run starts. The hook receives parent/child session keys, `contextMode` (`isolated` or `fork`), available transcript ids/files, and optional TTL. If it returns a rollback handle, OpenClaw calls it when spawn fails after preparation succeeds.
|
||||
Prepare shared context state before a child run starts. The hook receives parent/child session keys, `contextMode` (`isolated` or `fork`), available transcript ids/files, and optional TTL. If it returns a rollback handle, OpenClaw calls it when spawn fails after preparation succeeds. Native subagent spawns that request `lightContext` and resolve to `contextMode="isolated"` intentionally skip this hook so the child starts from the lightweight bootstrap context without context-engine-managed pre-spawn state.
|
||||
</ParamField>
|
||||
<ParamField path="onSubagentEnded" type="method">
|
||||
Clean up when a subagent session completes or is swept.
|
||||
|
||||
@@ -120,6 +120,19 @@ sparse token/cache counters from the latest transcript usage entry, and
|
||||
the caller's current session; visible client labels such as `openclaw-tui` are
|
||||
not session keys.
|
||||
|
||||
When route metadata is available, `session_status` also includes a visible
|
||||
`Route context` JSON block and matching structured `details` fields. These
|
||||
fields disambiguate the session key from the route that is currently handling
|
||||
the live run:
|
||||
|
||||
- `origin` is where the session was created, or the provider inferred from a
|
||||
deliverable session-key prefix when older state lacks stored origin metadata.
|
||||
- `active` is the current live-run route. It is only reported for the live or
|
||||
current session being handled now.
|
||||
- `deliveryContext` is the persisted delivery route stored on the session,
|
||||
which OpenClaw can reuse for later delivery even when the active surface
|
||||
differs.
|
||||
|
||||
`sessions_yield` intentionally ends the current turn so the next message can be
|
||||
the follow-up event you are waiting for. Use it after spawning sub-agents when
|
||||
you want completion results to arrive as the next message instead of building
|
||||
|
||||
@@ -76,6 +76,37 @@ Server globs use the provider-safe MCP server prefix, not necessarily the raw `m
|
||||
|
||||
Without that sandbox-layer entry, the MCP server can still load successfully while its tools are filtered before the provider request. Use `openclaw doctor` to catch this shape for OpenClaw-managed servers in `mcp.servers`. MCP servers loaded from bundled plugin manifests or Claude `.mcp.json` use the same sandbox gate, but this diagnostic does not enumerate those sources yet; use the same allowlist entries if their tools disappear in sandboxed turns.
|
||||
|
||||
### `tools.codeMode`
|
||||
|
||||
`tools.codeMode` enables the generic OpenClaw code-mode surface. When enabled
|
||||
for a run with tools, the model sees only `exec` and `wait`; normal OpenClaw
|
||||
tools move behind the in-sandbox `tools.*` catalog bridge, and MCP tools are
|
||||
available through the generated `MCP` namespace.
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: {
|
||||
codeMode: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
The shorthand is also accepted:
|
||||
|
||||
```json5
|
||||
{
|
||||
tools: { codeMode: true },
|
||||
}
|
||||
```
|
||||
|
||||
MCP declarations are exposed through the read-only virtual API file surface in
|
||||
code mode. Guest code can call `API.list("mcp")` and
|
||||
`API.read("mcp/<server>.d.ts")` to inspect TypeScript-style signatures before
|
||||
calling `MCP.<server>.<tool>()`. See [Code mode](/reference/code-mode) for the
|
||||
runtime contract, limits, and debugging steps.
|
||||
|
||||
### `tools.allow` / `tools.deny`
|
||||
|
||||
Global tool allow/deny policy (deny wins). Case-insensitive, supports `*` wildcards. Applied even when Docker sandbox is off.
|
||||
|
||||
@@ -100,6 +100,11 @@ The shorthand is also accepted:
|
||||
Code mode remains off when `tools.codeMode` is omitted, `false`, or an object
|
||||
without `enabled: true`.
|
||||
|
||||
When you use sandboxed agents with configured MCP servers, also make sure the
|
||||
sandbox tool policy allows the bundled MCP plugin, for example with
|
||||
`tools.sandbox.tools.alsoAllow: ["bundle-mcp"]`. See
|
||||
[Configuration - tools and custom providers](/gateway/config-tools#mcp-and-plugin-tools-inside-sandbox-tool-policy).
|
||||
|
||||
Use explicit limits when you want tighter bounds:
|
||||
|
||||
```json5
|
||||
@@ -441,12 +446,13 @@ const hits = await tools.web_search({ query: "OpenClaw code mode" });
|
||||
|
||||
MCP catalog entries are not callable through `tools.call(...)` or convenience
|
||||
functions in code mode. They are exposed only through the generated `MCP`
|
||||
namespace, which includes TypeScript-style API headers for discovery:
|
||||
namespace. TypeScript-style declaration files are available through the
|
||||
read-only `API` virtual file surface, so agents can inspect MCP signatures
|
||||
without adding MCP schemas to the prompt:
|
||||
|
||||
```typescript
|
||||
const servers = await MCP.$api();
|
||||
const githubApi = await MCP.github.$api();
|
||||
const createIssueApi = await MCP.github.$api("createIssue", { schema: true });
|
||||
const files = await API.list("mcp");
|
||||
const githubApi = await API.read("mcp/github.d.ts");
|
||||
|
||||
const issue = await MCP.github.createIssue({
|
||||
owner: "openclaw",
|
||||
@@ -462,7 +468,8 @@ const prompt = await MCP.docs.prompts.get({
|
||||
});
|
||||
```
|
||||
|
||||
`MCP.<server>.$api()` returns a compact header inferred from MCP tool metadata:
|
||||
`API.read("mcp/<server>.d.ts")` returns compact declarations inferred from MCP
|
||||
tool metadata:
|
||||
|
||||
```typescript
|
||||
type McpToolResult = {
|
||||
@@ -491,6 +498,20 @@ declare namespace MCP.github {
|
||||
}
|
||||
```
|
||||
|
||||
The declaration files are virtual, not files written under the workspace or
|
||||
state directory. For each code-mode `exec` call, OpenClaw builds the run-scoped
|
||||
tool catalog, keeps the visible MCP entries, renders `mcp/index.d.ts` plus one
|
||||
`mcp/<server>.d.ts` declaration per visible server, and injects that small
|
||||
read-only table into the QuickJS worker. Guest code sees only the `API` object:
|
||||
`API.list(prefix?)` returns file metadata and `API.read(path)` returns the
|
||||
selected declaration content. Unknown paths and `.` / `..` segments are rejected.
|
||||
|
||||
This keeps large MCP schemas out of the model prompt. The agent learns that the
|
||||
virtual API exists from the `exec` tool description, reads only the needed
|
||||
declaration file, and then calls `MCP.<server>.<tool>()` with one object argument.
|
||||
`MCP.<server>.$api()` remains available as an inline fallback when the agent
|
||||
needs a single-tool schema response inside the program.
|
||||
|
||||
The guest runtime must not expose host objects directly. Inputs and outputs cross
|
||||
the bridge as JSON-compatible values with explicit size caps.
|
||||
|
||||
@@ -981,8 +1002,9 @@ Code mode coverage should prove:
|
||||
- all effective non-MCP tools appear in `ALL_TOOLS`
|
||||
- denied tools do not appear in `ALL_TOOLS`
|
||||
- `tools.search`, `tools.describe`, and `tools.call` work for OpenClaw tools
|
||||
- MCP namespace `$api()` returns TypeScript-style headers inferred from MCP
|
||||
schemas
|
||||
- `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` expose TypeScript-style
|
||||
MCP declarations without a bridge/tool call
|
||||
- MCP namespace `$api()` remains available as an inline fallback for schemas
|
||||
- MCP namespace calls work for visible MCP tools with one object input, while
|
||||
direct MCP catalog entries are absent from `tools.*`
|
||||
- Tool Search control tools are hidden from both the model surface and the hidden
|
||||
@@ -1014,8 +1036,8 @@ Run these as integration or end-to-end tests when changing the runtime:
|
||||
7. In `exec`, read `ALL_TOOLS` and assert the effective test tools are present.
|
||||
8. In `exec`, call OpenClaw/plugin/client tools through `tools.search`,
|
||||
`tools.describe`, and `tools.call`.
|
||||
9. In `exec`, call `MCP.$api()` and `MCP.<server>.$api()` and assert the headers
|
||||
describe visible MCP tools.
|
||||
9. In `exec`, call `API.list("mcp")` and `API.read("mcp/<server>.d.ts")` and
|
||||
assert the declaration files describe visible MCP tools.
|
||||
10. In `exec`, call MCP tools through `MCP.<server>.<tool>({ ...input })` and
|
||||
assert direct MCP catalog entries are absent from `ALL_TOOLS` and `tools.*`.
|
||||
11. Assert denied tools are absent and cannot be called by guessed id.
|
||||
|
||||
@@ -262,7 +262,12 @@ async function terminatePids(
|
||||
deps: AcpxProcessCleanupDeps | undefined,
|
||||
): Promise<number[]> {
|
||||
const killProcess = deps?.killProcess ?? ((pid, signal) => process.kill(pid, signal));
|
||||
const sleep = deps?.sleep ?? ((ms) => new Promise<void>((resolve) => setTimeout(resolve, ms)));
|
||||
const sleep =
|
||||
deps?.sleep ??
|
||||
((ms) =>
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
}));
|
||||
const terminated: number[] = [];
|
||||
|
||||
for (const pid of pids) {
|
||||
@@ -302,7 +307,7 @@ export async function cleanupOpenClawOwnedAcpxProcessTree(params: {
|
||||
return { inspectedPids: [], terminatedPids: [], skippedReason: "missing-root" };
|
||||
}
|
||||
|
||||
let processes: AcpxProcessInfo[] = [];
|
||||
let processes: AcpxProcessInfo[];
|
||||
try {
|
||||
processes = await (params.deps?.listProcesses ?? listPlatformProcesses)();
|
||||
} catch {
|
||||
|
||||
@@ -1196,7 +1196,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
const record = await this.sessionStore.load(
|
||||
input.handle.acpxRecordId ?? input.handle.sessionKey,
|
||||
);
|
||||
let closeSucceeded = false;
|
||||
let closeSucceeded;
|
||||
try {
|
||||
await this.resolveDelegateForLoadedRecord(input.handle, record).close({
|
||||
handle: input.handle,
|
||||
|
||||
@@ -2958,7 +2958,9 @@ describe("active-memory plugin", () => {
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
runEmbeddedAgent.mockImplementationOnce(async (params: { timeoutMs?: number }) => {
|
||||
await new Promise((resolve) => setTimeout(resolve, (params.timeoutMs ?? 0) + 5));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, (params.timeoutMs ?? 0) + 5);
|
||||
});
|
||||
return {
|
||||
payloads: [{ text: "late timeout payload that should never become memory context" }],
|
||||
meta: { aborted: true },
|
||||
@@ -3001,7 +3003,9 @@ describe("active-memory plugin", () => {
|
||||
};
|
||||
plugin.register(api as unknown as OpenClawPluginApi);
|
||||
runEmbeddedAgent.mockImplementationOnce(async () => {
|
||||
await new Promise((resolve) => setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, CONFIGURED_TIMEOUT_MS + 5);
|
||||
});
|
||||
return { payloads: [{ text: "remember the ramen place" }] };
|
||||
});
|
||||
|
||||
@@ -3131,7 +3135,9 @@ describe("active-memory plugin", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 35);
|
||||
});
|
||||
return { payloads: [{ text: "User usually orders ramen." }] };
|
||||
});
|
||||
|
||||
@@ -3221,7 +3227,9 @@ describe("active-memory plugin", () => {
|
||||
},
|
||||
},
|
||||
]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 35));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 35);
|
||||
});
|
||||
return { payloads: [{ text: "User usually orders ramen after late flights." }] };
|
||||
});
|
||||
|
||||
|
||||
@@ -1793,7 +1793,9 @@ function watchTerminalMemorySearchResult(params: {
|
||||
if (stopped) {
|
||||
return;
|
||||
}
|
||||
timeoutId = setTimeout(tick, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
|
||||
timeoutId = setTimeout(() => {
|
||||
void tick();
|
||||
}, TERMINAL_MEMORY_SEARCH_POLL_INTERVAL_MS);
|
||||
timeoutId.unref?.();
|
||||
};
|
||||
const tick = async () => {
|
||||
|
||||
@@ -42,7 +42,9 @@ import { BrowserCdpEndpointBlockedError } from "./errors.js";
|
||||
|
||||
async function startWsServer() {
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
return { wss, port, url: `ws://127.0.0.1:${port}/devtools/browser/TEST` };
|
||||
}
|
||||
@@ -55,7 +57,9 @@ describe("cdp.helpers internal", () => {
|
||||
registerManagedProxyBrowserCdpBypassMock.mockReset();
|
||||
registerManagedProxyBrowserCdpBypassMock.mockImplementation(() => undefined);
|
||||
if (wss) {
|
||||
await new Promise<void>((resolve) => wss?.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss?.close(() => resolve());
|
||||
});
|
||||
wss = null;
|
||||
}
|
||||
});
|
||||
@@ -307,7 +311,9 @@ describe("cdp.helpers internal", () => {
|
||||
cb(true);
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => wss?.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss?.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
let callbackCount = 0;
|
||||
wss.on("connection", (socket) => {
|
||||
@@ -341,7 +347,9 @@ describe("cdp.helpers internal", () => {
|
||||
cb(false, 429, "too many requests");
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => wss?.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss?.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
|
||||
await expect(
|
||||
|
||||
@@ -397,7 +397,9 @@ type CdpSocketOptions = {
|
||||
};
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeRetryCount(value: number | undefined, fallback: number): number {
|
||||
|
||||
@@ -79,7 +79,9 @@ function replyToViewportCommandOrScreenshot(
|
||||
|
||||
async function startMockWsServer(handle: CdpReplyHandler) {
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
wss.on("connection", (socket) => {
|
||||
socket.on("message", (raw) => {
|
||||
@@ -113,7 +115,9 @@ describe("cdp internal", () => {
|
||||
|
||||
afterEach(async () => {
|
||||
if (wss) {
|
||||
await new Promise<void>((resolve) => wss?.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss?.close(() => resolve());
|
||||
});
|
||||
wss = null;
|
||||
}
|
||||
});
|
||||
@@ -1072,7 +1076,9 @@ describe("cdp internal", () => {
|
||||
// in createTargetViaCdp — the bare-ws root triggers discovery.
|
||||
const http = await import("node:http");
|
||||
const wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wsServer.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wsServer.once("listening", () => resolve());
|
||||
});
|
||||
const wsPort = (wsServer.address() as { port: number }).port;
|
||||
wsServer.on("connection", (socket) => {
|
||||
socket.on("message", (raw) => {
|
||||
@@ -1110,7 +1116,9 @@ describe("cdp internal", () => {
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => httpServer.listen(0, "127.0.0.1", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
const httpPort = (httpServer.address() as { port: number }).port;
|
||||
try {
|
||||
const out = await createTargetViaCdp({
|
||||
@@ -1119,8 +1127,12 @@ describe("cdp internal", () => {
|
||||
});
|
||||
expect(out.targetId).toBe("T_BARE_WS");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wsServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => httpServer.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wsServer.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -27,7 +27,9 @@ describe("cdp", () => {
|
||||
|
||||
const startWsServer = async () => {
|
||||
wsServer = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wsServer?.once("listening", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
wsServer?.once("listening", resolve);
|
||||
});
|
||||
return (wsServer.address() as { port: number }).port;
|
||||
};
|
||||
|
||||
@@ -77,7 +79,9 @@ describe("cdp", () => {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer?.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
return (httpServer.address() as { port: number }).port;
|
||||
};
|
||||
|
||||
@@ -85,14 +89,16 @@ describe("cdp", () => {
|
||||
vi.unstubAllEnvs();
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!httpServer) {
|
||||
return resolve();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
httpServer.close(() => resolve());
|
||||
httpServer = null;
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
if (!wsServer) {
|
||||
return resolve();
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
wsServer.close(() => resolve());
|
||||
wsServer = null;
|
||||
@@ -190,7 +196,9 @@ describe("cdp", () => {
|
||||
res.statusCode = 404;
|
||||
res.end("not found");
|
||||
});
|
||||
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer?.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const httpPort = (httpServer.address() as AddressInfo).port;
|
||||
|
||||
await expect(
|
||||
@@ -210,7 +218,9 @@ describe("cdp", () => {
|
||||
heldSockets.push(socket);
|
||||
// Hold the TCP connection open without completing the WebSocket handshake.
|
||||
});
|
||||
await new Promise<void>((resolve) => httpServer?.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
httpServer?.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (httpServer.address() as AddressInfo).port;
|
||||
|
||||
try {
|
||||
@@ -507,7 +517,9 @@ describe("cdp", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
try {
|
||||
const addr = server.address() as AddressInfo;
|
||||
const created = await createTargetViaCdp({
|
||||
@@ -516,8 +528,12 @@ describe("cdp", () => {
|
||||
});
|
||||
expect(created.targetId).toBe("ROOT_FALLBACK");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -137,7 +137,7 @@ async function diagnoseCdpHealthCommand(
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
let parsed: { id?: unknown; result?: unknown } | null = null;
|
||||
let parsed: { id?: unknown; result?: unknown } | null;
|
||||
try {
|
||||
parsed = JSON.parse(rawDataToString(raw)) as { id?: unknown; result?: unknown };
|
||||
} catch {
|
||||
|
||||
@@ -194,8 +194,12 @@ async function withMockChromeCdpServer(params: {
|
||||
const addr = server.address() as AddressInfo;
|
||||
await params.run(`http://127.0.0.1:${addr.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -952,9 +956,13 @@ describe("chrome.ts internal", () => {
|
||||
it("resolves false when the direct-ws probe cannot connect", async () => {
|
||||
// Bind a ws server and then close it, so connecting to it fails.
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await expect(
|
||||
isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/GONE`, 50),
|
||||
).resolves.toBe(false);
|
||||
@@ -962,7 +970,9 @@ describe("chrome.ts internal", () => {
|
||||
|
||||
it("resolves true when the direct-ws handshake succeeds", async () => {
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as { port: number }).port;
|
||||
try {
|
||||
// Direct /devtools/ WS URL — isChromeReachable goes through
|
||||
@@ -972,7 +982,9 @@ describe("chrome.ts internal", () => {
|
||||
isChromeReachable(`ws://127.0.0.1:${port}/devtools/browser/OK`, 500),
|
||||
).resolves.toBe(true);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -994,9 +1006,13 @@ describe("chrome.ts internal", () => {
|
||||
// accepting ws upgrades — the canRunCdpHealthCommand probe will
|
||||
// fire its 'error' handler during handshake.
|
||||
const dead = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => dead.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
dead.once("listening", () => resolve());
|
||||
});
|
||||
const deadPort = (dead.address() as { port: number }).port;
|
||||
await new Promise<void>((resolve) => dead.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
dead.close(() => resolve());
|
||||
});
|
||||
const server = createServer((req, res) => {
|
||||
if (req.url === "/json/version") {
|
||||
res.writeHead(200, { "Content-Type": "application/json" });
|
||||
@@ -1009,14 +1025,18 @@ describe("chrome.ts internal", () => {
|
||||
}
|
||||
res.writeHead(404).end();
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", () => resolve());
|
||||
});
|
||||
try {
|
||||
const addr = server.address() as AddressInfo;
|
||||
await expect(isChromeCdpReady(`http://127.0.0.1:${addr.port}`, 50, 10)).resolves.toBe(
|
||||
false,
|
||||
);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -42,14 +42,12 @@ async function startLoopbackCdpServer(): Promise<RunningServer> {
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(
|
||||
runningServers
|
||||
.splice(0)
|
||||
.map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
),
|
||||
),
|
||||
runningServers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
@@ -108,8 +108,12 @@ async function withMockChromeCdpServer(params: {
|
||||
const addr = server.address() as AddressInfo;
|
||||
await params.run(`http://127.0.0.1:${addr.port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -549,7 +553,9 @@ describe("browser chrome helpers", () => {
|
||||
}),
|
||||
).rejects.toBeInstanceOf(BrowserCdpEndpointBlockedError);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -559,7 +565,7 @@ describe("browser chrome helpers", () => {
|
||||
onConnection: (wss) => {
|
||||
wss.on("connection", (ws) => {
|
||||
ws.on("message", (raw) => {
|
||||
let message: { id?: unknown; method?: unknown } | null = null;
|
||||
let message: { id?: unknown; method?: unknown } | null;
|
||||
try {
|
||||
const text =
|
||||
typeof raw === "string"
|
||||
@@ -755,8 +761,12 @@ describe("browser chrome helpers", () => {
|
||||
expect(diagnostic.wsUrl).toBe(wsOnlyBase);
|
||||
expect(diagnostic.browser).toBe("Browserless/Mock");
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -785,12 +795,16 @@ describe("browser chrome helpers", () => {
|
||||
);
|
||||
// A real WS server accepts the handshake.
|
||||
const wss = new WebSocketServer({ port: 0, host: "127.0.0.1" });
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as AddressInfo).port;
|
||||
try {
|
||||
await expect(isChromeReachable(`ws://127.0.0.1:${port}`, 500)).resolves.toBe(true);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
@@ -811,7 +825,9 @@ describe("browser chrome helpers", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => wss.once("listening", () => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.once("listening", () => resolve());
|
||||
});
|
||||
const port = (wss.address() as AddressInfo).port;
|
||||
try {
|
||||
await expect(isChromeCdpReady(`ws://127.0.0.1:${port}`, 500, 500)).resolves.toBe(true);
|
||||
@@ -820,7 +836,9 @@ describe("browser chrome helpers", () => {
|
||||
);
|
||||
expect(diagnostic.wsUrl).toBe(`ws://127.0.0.1:${port}`);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -519,7 +519,9 @@ export async function launchOpenClawChrome(
|
||||
if (exists(localStatePath) && exists(preferencesPath)) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_BOOTSTRAP_PREFS_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CHROME_BOOTSTRAP_PREFS_POLL_MS);
|
||||
});
|
||||
}
|
||||
try {
|
||||
bootstrap.kill("SIGTERM");
|
||||
@@ -531,7 +533,9 @@ export async function launchOpenClawChrome(
|
||||
if (bootstrap.exitCode != null) {
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_BOOTSTRAP_EXIT_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CHROME_BOOTSTRAP_EXIT_POLL_MS);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -577,7 +581,9 @@ export async function launchOpenClawChrome(
|
||||
launchHttpReachable = true;
|
||||
break;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_LAUNCH_READY_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CHROME_LAUNCH_READY_POLL_MS);
|
||||
});
|
||||
}
|
||||
|
||||
if (!launchHttpReachable) {
|
||||
@@ -682,7 +688,9 @@ export async function stopOpenClawChrome(
|
||||
return;
|
||||
}
|
||||
const remainingMs = timeoutMs - (Date.now() - start);
|
||||
await new Promise((r) => setTimeout(r, Math.max(1, Math.min(100, remainingMs))));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, Math.max(1, Math.min(100, remainingMs)));
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
|
||||
@@ -37,7 +37,9 @@ describe("browser client fetch attachOnly diagnostics", () => {
|
||||
socket.on("close", () => sockets.delete(socket));
|
||||
socket.on("error", () => {});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.listen(0, "127.0.0.1", resolve);
|
||||
});
|
||||
const port = (server.address() as { port: number }).port;
|
||||
const configPath = path.join(tempHome.home, ".openclaw", "openclaw.json");
|
||||
await fs.writeFile(
|
||||
@@ -78,7 +80,9 @@ describe("browser client fetch attachOnly diagnostics", () => {
|
||||
for (const socket of sockets) {
|
||||
socket.destroy();
|
||||
}
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -469,7 +469,7 @@ export function resolveProfile(
|
||||
const rawProfileUrl = profile.cdpUrl?.trim() ?? "";
|
||||
let cdpHost = resolved.cdpHost;
|
||||
let cdpPort = profile.cdpPort ?? 0;
|
||||
let cdpUrl = "";
|
||||
let cdpUrl;
|
||||
const driver = profile.driver === "existing-session" ? "existing-session" : "openclaw";
|
||||
const headless = profile.headless ?? resolved.headless;
|
||||
const headlessSource =
|
||||
|
||||
@@ -212,7 +212,9 @@ describe("pw-session ensurePageState", () => {
|
||||
|
||||
try {
|
||||
handlers.get("download")?.[0]?.(download);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(unhandled).toStrictEqual([]);
|
||||
await expect(download.path?.()).rejects.toThrow("save failed");
|
||||
|
||||
@@ -947,7 +947,9 @@ async function connectBrowser(cdpUrl: string, ssrfPolicy?: SsrFPolicy): Promise<
|
||||
break;
|
||||
}
|
||||
const delay = resolveCdpConnectRetryDelayMs(attempt);
|
||||
await new Promise((r) => setTimeout(r, delay));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, delay);
|
||||
});
|
||||
}
|
||||
}
|
||||
if (lastErr instanceof Error) {
|
||||
@@ -1066,7 +1068,7 @@ async function findPageByTargetId(
|
||||
const pages = await getAllPages(browser);
|
||||
let resolvedViaCdp = false;
|
||||
for (const page of pages) {
|
||||
let tid: string | null = null;
|
||||
let tid: string | null;
|
||||
try {
|
||||
tid = await pageTargetId(page);
|
||||
resolvedViaCdp = true;
|
||||
@@ -1170,7 +1172,7 @@ export async function getPageForTargetId(opts: {
|
||||
}
|
||||
|
||||
function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
|
||||
let sameMainFrame = false;
|
||||
let sameMainFrame;
|
||||
try {
|
||||
sameMainFrame = request.frame() === page.mainFrame();
|
||||
} catch {
|
||||
@@ -1197,7 +1199,7 @@ function isTopLevelNavigationRequest(page: Page, request: Request): boolean {
|
||||
}
|
||||
|
||||
function isSubframeDocumentNavigationRequest(page: Page, request: Request): boolean {
|
||||
let sameMainFrame = false;
|
||||
let sameMainFrame;
|
||||
try {
|
||||
sameMainFrame = request.frame() === page.mainFrame();
|
||||
} catch {
|
||||
|
||||
@@ -581,7 +581,9 @@ export async function clickViaPlaywright(opts: {
|
||||
abortPromise,
|
||||
reconcileRemoteDialog,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, delayMs));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, delayMs);
|
||||
});
|
||||
}
|
||||
if (opts.doubleClick) {
|
||||
await awaitActionWithAbort(
|
||||
|
||||
@@ -45,7 +45,9 @@ import type { BrowserRouteRegistrar } from "./types.js";
|
||||
import { asyncBrowserRoute, jsonError, toStringOrEmpty } from "./utils.js";
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
const EXISTING_SESSION_INTERACTION_NAVIGATION_RECHECK_DELAYS_MS = [0, 250, 500] as const;
|
||||
|
||||
@@ -34,7 +34,9 @@ export async function resolveTargetIdAfterNavigate(opts: {
|
||||
const first = pickReplacement(await opts.listTabs());
|
||||
currentTargetId = first.targetId;
|
||||
if (first.shouldRetry) {
|
||||
await new Promise((r) => setTimeout(r, opts.retryDelayMs ?? 800));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, opts.retryDelayMs ?? 800);
|
||||
});
|
||||
currentTargetId = pickReplacement(await opts.listTabs(), {
|
||||
allowSingleTabFallback: true,
|
||||
}).targetId;
|
||||
|
||||
@@ -286,7 +286,9 @@ export function createProfileAvailability({
|
||||
if (await isReachable(attemptTimeoutMs)) {
|
||||
return;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CDP_READY_AFTER_LAUNCH_POLL_MS);
|
||||
});
|
||||
}
|
||||
throw new Error(
|
||||
`Chrome CDP websocket for profile "${profile.name}" is not reachable after start. ${await describeCdpFailure(
|
||||
@@ -306,7 +308,9 @@ export function createProfileAvailability({
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, CHROME_MCP_ATTACH_READY_POLL_MS);
|
||||
});
|
||||
}
|
||||
throw new BrowserProfileUnavailableError(formatChromeMcpAttachFailure(lastError));
|
||||
};
|
||||
|
||||
@@ -350,7 +350,9 @@ export function createProfileTabOps({
|
||||
triggerManagedTabLimit(found.targetId);
|
||||
return assignTabAlias({ profileState, tab: found, label: opts?.label });
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS));
|
||||
await new Promise((r) => {
|
||||
setTimeout(r, OPEN_TAB_DISCOVERY_POLL_MS);
|
||||
});
|
||||
}
|
||||
triggerManagedTabLimit(createdViaCdp);
|
||||
return assignTabAlias({
|
||||
|
||||
@@ -21,7 +21,9 @@ function isTransientStartupFetchError(error: unknown): boolean {
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function postStartWithRetry(params: {
|
||||
|
||||
@@ -41,7 +41,9 @@ describe("browser control HTTP auth", () => {
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
await new Promise<void>((resolve) => current.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
current.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
it("requires bearer auth for standalone browser HTTP routes", async () => {
|
||||
|
||||
@@ -150,7 +150,7 @@ function formatDoctorLine(check: BrowserDoctorCheck): string {
|
||||
|
||||
async function runBrowserDoctor(parent: BrowserParentOpts, profile?: string, deep?: boolean) {
|
||||
const checks: BrowserDoctorCheck[] = [];
|
||||
let status: BrowserStatus | null = null;
|
||||
let status: BrowserStatus | null;
|
||||
|
||||
try {
|
||||
status = await fetchBrowserStatus(parent, profile);
|
||||
|
||||
@@ -171,7 +171,7 @@ export async function handleBrowserGatewayRequest({
|
||||
}
|
||||
|
||||
const cfg = getRuntimeConfig();
|
||||
let nodeTarget: NodeSession | null = null;
|
||||
let nodeTarget: NodeSession | null;
|
||||
try {
|
||||
nodeTarget = resolveBrowserNodeTarget({
|
||||
cfg,
|
||||
|
||||
@@ -388,7 +388,6 @@ describe("canvas host", () => {
|
||||
const linkName = `test-link-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`;
|
||||
const linkPath = path.join(a2uiRoot, linkName);
|
||||
let createdBundle = false;
|
||||
let createdLink = false;
|
||||
|
||||
try {
|
||||
await fs.stat(bundlePath);
|
||||
@@ -398,7 +397,6 @@ describe("canvas host", () => {
|
||||
}
|
||||
|
||||
await fs.symlink(path.join(process.cwd(), "package.json"), linkPath);
|
||||
createdLink = true;
|
||||
|
||||
try {
|
||||
const res = await captureA2uiResponse(`${A2UI_PATH}/`);
|
||||
@@ -421,9 +419,7 @@ describe("canvas host", () => {
|
||||
expect(symlinkRes.status).toBe(404);
|
||||
expect(symlinkRes.body).toBe("not found");
|
||||
} finally {
|
||||
if (createdLink) {
|
||||
await fs.rm(linkPath, { force: true });
|
||||
}
|
||||
await fs.rm(linkPath, { force: true });
|
||||
if (createdBundle) {
|
||||
await fs.rm(bundlePath, { force: true });
|
||||
}
|
||||
|
||||
@@ -443,7 +443,9 @@ export async function createCanvasHostHandler(
|
||||
}
|
||||
}
|
||||
if (wss) {
|
||||
await new Promise<void>((resolve) => wss.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
wss.close(() => resolve());
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
@@ -528,9 +530,9 @@ export async function startCanvasHost(opts: CanvasHostServerOpts): Promise<Canva
|
||||
if (ownsHandler) {
|
||||
await handler.close();
|
||||
}
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((err) => (err ? reject(err) : resolve())),
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((err) => (err ? reject(err) : resolve()));
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -121,7 +121,9 @@ describe("ClickClack gateway", () => {
|
||||
await vi.waitFor(() => expect(mocks.client.websocket).toHaveBeenCalledTimes(1));
|
||||
|
||||
socket.emit("message", Buffer.from("{not json"));
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(runError).toBeUndefined();
|
||||
expect(ctx.log?.warn).toHaveBeenCalledWith(
|
||||
"[default] skipped malformed ClickClack websocket event",
|
||||
|
||||
@@ -190,7 +190,9 @@ export async function startClickClackGatewayAccount(
|
||||
socket.on("error", reject);
|
||||
});
|
||||
if (!ctx.abortSignal.aborted) {
|
||||
await new Promise((resolve) => setTimeout(resolve, account.reconnectMs));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, account.reconnectMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
ctx.setStatus({ accountId: account.accountId, running: false });
|
||||
|
||||
@@ -790,7 +790,9 @@ async function waitForFile(filePath: string): Promise<string> {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
}
|
||||
}
|
||||
throw new Error(`timed out waiting for ${filePath}`);
|
||||
@@ -838,10 +840,14 @@ describe("connectCodexAppServerEndpoint", () => {
|
||||
await expect(
|
||||
Promise.race([
|
||||
probe,
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error("probe timed out")), 500)),
|
||||
new Promise((_, reject) => {
|
||||
setTimeout(() => reject(new Error("probe timed out")), 500);
|
||||
}),
|
||||
]),
|
||||
).resolves.toMatchObject([{ endpointId: "ws", ok: false }]);
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.close(() => resolve());
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects malformed stdio frames instead of throwing out of band", async () => {
|
||||
@@ -930,7 +936,9 @@ describe("connectCodexAppServerEndpoint", () => {
|
||||
);
|
||||
|
||||
await expect(supervisor.probeEndpoints()).resolves.toEqual([{ endpointId: "exits", ok: true }]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 50));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 50);
|
||||
});
|
||||
await expect(supervisor.probeEndpoints()).resolves.toMatchObject([
|
||||
{
|
||||
endpointId: "exits",
|
||||
|
||||
@@ -337,7 +337,6 @@ export async function startCodexAttemptThread(params: {
|
||||
if (startupClientForAbandonedRequestCleanup === failedClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
attemptedClient = undefined;
|
||||
if (attempt >= CODEX_APP_SERVER_STARTUP_CONNECTION_CLOSE_MAX_ATTEMPTS) {
|
||||
embeddedAgentLog.warn(
|
||||
"codex app-server connection closed during startup; retries exhausted",
|
||||
|
||||
@@ -147,7 +147,7 @@ describe("Codex app-server attempt timeouts", () => {
|
||||
}, 5);
|
||||
});
|
||||
},
|
||||
operation: async () => new Promise<never>(() => undefined),
|
||||
operation: async () => new Promise<never>(() => {}),
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
|
||||
@@ -164,7 +164,7 @@ describe("Codex app-server attempt timeouts", () => {
|
||||
const run = withCodexStartupTimeout({
|
||||
timeoutMs: 1_000,
|
||||
signal: controller.signal,
|
||||
operation: async () => new Promise<never>(() => undefined),
|
||||
operation: async () => new Promise<never>(() => {}),
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup aborted");
|
||||
|
||||
|
||||
@@ -486,7 +486,7 @@ describe("CodexAppServerClient", () => {
|
||||
clients.push(harness.client);
|
||||
harness.client.addRequestHandler((request) => {
|
||||
if (request.method === "item/tool/call") {
|
||||
return new Promise<never>(() => undefined);
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
return undefined;
|
||||
});
|
||||
|
||||
@@ -179,6 +179,32 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
expect(resolveCodexDynamicToolsLoading({}, privateQaCodexEnv)).toBe("direct");
|
||||
});
|
||||
|
||||
it("quarantines unreadable tool entries before Codex-specific filtering", async () => {
|
||||
const messageTool = createRuntimeDynamicTool("message");
|
||||
const sourceTools = new Proxy([messageTool] as RuntimeDynamicToolForTest[], {
|
||||
get(target, property, receiver) {
|
||||
if (property === "0") {
|
||||
throw new Error("fuzzplugin tool entry getter exploded");
|
||||
}
|
||||
if (property === "1") {
|
||||
return messageTool;
|
||||
}
|
||||
if (property === "length") {
|
||||
return 2;
|
||||
}
|
||||
return Reflect.get(target, property, receiver);
|
||||
},
|
||||
});
|
||||
setOpenClawCodingToolsFactoryForTests(() => sourceTools);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
|
||||
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
|
||||
});
|
||||
|
||||
it("limits Codex memory flush runs to managed read and write tools", async () => {
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
|
||||
@@ -2,6 +2,7 @@ import {
|
||||
buildAgentHookContextChannelFields,
|
||||
buildEmbeddedAttemptToolRunContext,
|
||||
embeddedAgentLog,
|
||||
filterProviderNormalizableTools,
|
||||
isSubagentSessionKey,
|
||||
normalizeAgentRuntimeTools,
|
||||
resolveAttemptSpawnWorkspaceDir,
|
||||
@@ -9,6 +10,7 @@ import {
|
||||
resolveSandboxContext,
|
||||
supportsModelTools,
|
||||
type EmbeddedRunAttemptParams,
|
||||
type RuntimeToolSchemaDiagnostic,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { isToolAllowed } from "openclaw/plugin-sdk/sandbox";
|
||||
@@ -265,15 +267,19 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
},
|
||||
});
|
||||
toolBuildStages.mark("create-openclaw-coding-tools");
|
||||
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
|
||||
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
|
||||
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
|
||||
const readableAllTools = [...readableAllToolProjection.tools];
|
||||
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
|
||||
addSandboxShellDynamicToolsIfAvailable(
|
||||
isCodexMemoryFlushRun(params)
|
||||
? filterCodexMemoryFlushDynamicTools(allTools)
|
||||
: filterCodexDynamicTools(allTools, input.pluginConfig),
|
||||
allTools,
|
||||
? filterCodexMemoryFlushDynamicTools(readableAllTools)
|
||||
: filterCodexDynamicTools(readableAllTools, input.pluginConfig),
|
||||
readableAllTools,
|
||||
input,
|
||||
),
|
||||
allTools,
|
||||
readableAllTools,
|
||||
input,
|
||||
);
|
||||
toolBuildStages.mark("codex-filtering");
|
||||
@@ -295,8 +301,25 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
modelId: params.modelId,
|
||||
modelApi: params.model.api,
|
||||
model: params.model,
|
||||
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
|
||||
preNormalizationDiagnostics.push(...diagnostics),
|
||||
});
|
||||
toolBuildStages.mark("runtime-normalization");
|
||||
if (preNormalizationDiagnostics.length > 0) {
|
||||
embeddedAgentLog.warn(
|
||||
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
|
||||
{
|
||||
runId: params.runId,
|
||||
sessionId: params.sessionId,
|
||||
diagnostics: preNormalizationDiagnostics.map((diagnostic) => ({
|
||||
index: diagnostic.toolIndex,
|
||||
tool: diagnostic.toolName,
|
||||
violations: diagnostic.violations.slice(0, 12),
|
||||
violationCount: diagnostic.violations.length,
|
||||
})),
|
||||
},
|
||||
);
|
||||
}
|
||||
const summary = toolBuildStages.snapshot();
|
||||
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
|
||||
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
|
||||
@@ -308,7 +331,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
|
||||
phase,
|
||||
totalMs: summary.totalMs,
|
||||
stages: summary.stages,
|
||||
allToolCount: allTools.length,
|
||||
allToolCount: readableAllTools.length,
|
||||
codexFilteredToolCount: codexFilteredTools.length,
|
||||
visionFilteredToolCount: visionFilteredTools.length,
|
||||
filteredToolCount: filteredTools.length,
|
||||
|
||||
@@ -194,7 +194,7 @@ describe("dynamic tool execution helpers", () => {
|
||||
toolBridge: {
|
||||
handleToolCall: vi.fn((_call, options) => {
|
||||
capturedSignal = options?.signal;
|
||||
return new Promise<never>(() => undefined);
|
||||
return new Promise<never>(() => {});
|
||||
}),
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
@@ -230,7 +230,7 @@ describe("dynamic tool execution helpers", () => {
|
||||
arguments: { action: "poll", sessionId: "process-session", timeout: 30_000 },
|
||||
},
|
||||
toolBridge: {
|
||||
handleToolCall: vi.fn(() => new Promise<never>(() => undefined)),
|
||||
handleToolCall: vi.fn(() => new Promise<never>(() => {})),
|
||||
},
|
||||
signal: new AbortController().signal,
|
||||
timeoutMs: 1,
|
||||
|
||||
@@ -35,7 +35,9 @@ const tinyPngBase64 =
|
||||
type ProjectorNotification = Parameters<CodexAppServerEventProjector["handleNotification"]>[0];
|
||||
|
||||
function flushDiagnosticEvents() {
|
||||
return new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function assistantMessage(text: string, timestamp: number) {
|
||||
|
||||
@@ -52,8 +52,30 @@ function createRuntime() {
|
||||
error?: string;
|
||||
}>;
|
||||
};
|
||||
const createRunningTaskRun = vi.fn(
|
||||
(params): AgentHarnessTaskRecord => ({
|
||||
taskId: params.sourceId ?? params.runId,
|
||||
runtime: "subagent",
|
||||
sourceId: params.sourceId,
|
||||
requesterSessionKey: "agent:main:main",
|
||||
ownerKey: "agent:main:main",
|
||||
scopeKind: "session",
|
||||
agentId: params.agentId,
|
||||
runId: params.runId,
|
||||
label: params.label,
|
||||
task: params.task,
|
||||
status: "running",
|
||||
deliveryStatus: params.deliveryStatus ?? "not_applicable",
|
||||
notifyPolicy: params.notifyPolicy ?? "silent",
|
||||
createdAt: params.startedAt ?? Date.now(),
|
||||
startedAt: params.startedAt,
|
||||
lastEventAt: params.lastEventAt,
|
||||
progressSummary: params.progressSummary,
|
||||
}),
|
||||
);
|
||||
const taskRuntime = {
|
||||
createRunningTaskRun: vi.fn(),
|
||||
createRunningTaskRun,
|
||||
tryCreateRunningTaskRun: vi.fn((params) => createRunningTaskRun(params)),
|
||||
recordTaskRunProgressByRunId: vi.fn(() => []),
|
||||
finalizeTaskRunByRunId: vi.fn(() => []),
|
||||
listTaskRecords: vi.fn((): AgentHarnessTaskRecord[] => []),
|
||||
|
||||
@@ -7,7 +7,7 @@ import {
|
||||
|
||||
function createRuntime() {
|
||||
return {
|
||||
createRunningTaskRun: vi.fn(),
|
||||
tryCreateRunningTaskRun: vi.fn((params) => ({ taskId: "task-native-subagent", ...params })),
|
||||
recordTaskRunProgressByRunId: vi.fn(() => []),
|
||||
finalizeTaskRunByRunId: vi.fn(() => []),
|
||||
} as unknown as TaskLifecycleRuntime;
|
||||
@@ -49,7 +49,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith({
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
|
||||
sourceId: "codex-thread:child-thread",
|
||||
agentId: "main",
|
||||
runId: "codex-thread:child-thread",
|
||||
@@ -62,7 +62,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
lastEventAt: 20_000,
|
||||
progressSummary: "Codex native subagent started.",
|
||||
});
|
||||
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
expect(vi.mocked(runtime.tryCreateRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"childSessionKey",
|
||||
);
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
@@ -99,7 +99,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).not.toHaveBeenCalled();
|
||||
expect(runtime.tryCreateRunningTaskRun).not.toHaveBeenCalled();
|
||||
expect(runtime.recordTaskRunProgressByRunId).not.toHaveBeenCalled();
|
||||
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -133,7 +133,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
mirror.handleNotification(notification);
|
||||
mirror.handleNotification(notification);
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledTimes(1);
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("maps Codex thread status changes onto the mirrored task run", () => {
|
||||
@@ -228,7 +228,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith({
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
|
||||
sourceId: "codex-thread:child-thread",
|
||||
runId: "codex-thread:child-thread",
|
||||
label: "Codex subagent",
|
||||
@@ -240,7 +240,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
lastEventAt: 40_000,
|
||||
progressSummary: "Codex native subagent spawned.",
|
||||
});
|
||||
expect(vi.mocked(runtime.createRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
expect(vi.mocked(runtime.tryCreateRunningTaskRun).mock.calls[0]?.[0]).not.toHaveProperty(
|
||||
"childSessionKey",
|
||||
);
|
||||
expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
|
||||
@@ -282,7 +282,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith(
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:child-thread",
|
||||
task: "inspect one thing",
|
||||
@@ -319,7 +319,7 @@ describe("CodexNativeSubagentTaskMirror", () => {
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.createRunningTaskRun).toHaveBeenCalledWith(
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "codex-thread:child-thread",
|
||||
task: "inspect one thing",
|
||||
|
||||
@@ -15,7 +15,7 @@ import { isJsonObject } from "./protocol.js";
|
||||
|
||||
export type TaskLifecycleRuntime = Pick<
|
||||
AgentHarnessTaskRuntime,
|
||||
"createRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
>;
|
||||
|
||||
export type CodexNativeSubagentTaskMirrorParams = {
|
||||
@@ -27,6 +27,7 @@ export type CodexNativeSubagentTaskMirrorParams = {
|
||||
|
||||
export class CodexNativeSubagentTaskMirror {
|
||||
private readonly mirroredThreadIds = new Set<string>();
|
||||
private readonly failedMirrorThreadIds = new Set<string>();
|
||||
private readonly terminalRunIds = new Set<string>();
|
||||
private readonly now: () => number;
|
||||
|
||||
@@ -81,7 +82,7 @@ export class CodexNativeSubagentTaskMirror {
|
||||
trimOptional(thread.preview) ??
|
||||
`Codex native subagent${label === "Codex subagent" ? "" : ` ${label}`}`;
|
||||
const createdAt = secondsToMillis(thread.createdAt) ?? this.now();
|
||||
this.runtime.createRunningTaskRun({
|
||||
const taskRecord = this.runtime.tryCreateRunningTaskRun({
|
||||
sourceId: runId,
|
||||
agentId: this.params.agentId,
|
||||
runId,
|
||||
@@ -94,6 +95,13 @@ export class CodexNativeSubagentTaskMirror {
|
||||
lastEventAt: this.now(),
|
||||
progressSummary: "Codex native subagent started.",
|
||||
});
|
||||
if (!taskRecord) {
|
||||
this.mirroredThreadIds.delete(threadId);
|
||||
this.failedMirrorThreadIds.add(threadId);
|
||||
return;
|
||||
}
|
||||
this.failedMirrorThreadIds.delete(threadId);
|
||||
this.terminalRunIds.delete(runId);
|
||||
this.applyStatus(threadId, thread.status);
|
||||
}
|
||||
|
||||
@@ -106,6 +114,9 @@ export class CodexNativeSubagentTaskMirror {
|
||||
}
|
||||
|
||||
private applyStatus(threadId: string, status: CodexThreadStatus | null | undefined): void {
|
||||
if (!this.mirroredThreadIds.has(threadId) && this.failedMirrorThreadIds.has(threadId)) {
|
||||
return;
|
||||
}
|
||||
const statusType = status?.type;
|
||||
if (!statusType) {
|
||||
return;
|
||||
@@ -219,7 +230,7 @@ export class CodexNativeSubagentTaskMirror {
|
||||
const prompt = trimOptional(readString(item, "prompt"));
|
||||
const runId = codexNativeSubagentRunId(normalizedThreadId);
|
||||
const createdAt = this.now();
|
||||
this.runtime.createRunningTaskRun({
|
||||
const taskRecord = this.runtime.tryCreateRunningTaskRun({
|
||||
sourceId: runId,
|
||||
agentId: this.params.agentId,
|
||||
runId,
|
||||
@@ -232,6 +243,13 @@ export class CodexNativeSubagentTaskMirror {
|
||||
lastEventAt: createdAt,
|
||||
progressSummary: "Codex native subagent spawned.",
|
||||
});
|
||||
if (!taskRecord) {
|
||||
this.mirroredThreadIds.delete(normalizedThreadId);
|
||||
this.failedMirrorThreadIds.add(normalizedThreadId);
|
||||
return;
|
||||
}
|
||||
this.failedMirrorThreadIds.delete(normalizedThreadId);
|
||||
this.terminalRunIds.delete(runId);
|
||||
}
|
||||
|
||||
private applyCollabAgentStatus(
|
||||
@@ -239,6 +257,9 @@ export class CodexNativeSubagentTaskMirror {
|
||||
status: string | undefined,
|
||||
message: string | null | undefined,
|
||||
): void {
|
||||
if (!this.mirroredThreadIds.has(threadId) && this.failedMirrorThreadIds.has(threadId)) {
|
||||
return;
|
||||
}
|
||||
const normalizedStatus = normalizeAgentStateStatus(status);
|
||||
if (!normalizedStatus) {
|
||||
return;
|
||||
|
||||
@@ -85,7 +85,9 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
|
||||
}
|
||||
await Promise.race([
|
||||
Promise.allSettled(attempts.map((attempt) => attempt.promise)),
|
||||
new Promise<void>((resolve) => setTimeout(resolve, 5_000)),
|
||||
new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 5_000);
|
||||
}),
|
||||
]);
|
||||
}
|
||||
|
||||
|
||||
@@ -66,7 +66,9 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
expect(llmInput).toHaveBeenCalled();
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
const [llmInputPayload, llmInputContext] = mockCall(llmInput, "llm_input") as [
|
||||
{
|
||||
|
||||
@@ -817,7 +817,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
onTimeout: async () => {
|
||||
await releaseCodexSandboxExecServerEnvironment(sandbox);
|
||||
},
|
||||
operation: async () => new Promise<never>(() => undefined),
|
||||
operation: async () => new Promise<never>(() => {}),
|
||||
}),
|
||||
).rejects.toThrow("codex app-server startup timed out");
|
||||
|
||||
@@ -1111,7 +1111,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
@@ -1684,7 +1686,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -1725,7 +1729,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -1762,7 +1768,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -1801,7 +1809,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -1846,7 +1856,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -1891,7 +1903,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -2134,7 +2148,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
@@ -2189,7 +2205,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
@@ -2266,7 +2284,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
@@ -2454,7 +2474,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir));
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
@@ -2483,7 +2505,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -2526,7 +2550,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -2566,7 +2592,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
const result = await run;
|
||||
|
||||
@@ -2609,7 +2637,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -2851,7 +2881,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(true);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(unhandledRejections).toStrictEqual([]);
|
||||
} finally {
|
||||
process.off("unhandledRejection", onUnhandledRejection);
|
||||
@@ -2943,7 +2975,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
{ turnTerminalIdleTimeoutMs: 60_000 },
|
||||
);
|
||||
await bufferedTerminal;
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
harness.close();
|
||||
|
||||
const result = await run;
|
||||
@@ -2983,7 +3017,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
turnTerminalIdleTimeoutMs: 60_000,
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
|
||||
@@ -3076,7 +3112,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
await harness.notify({
|
||||
@@ -3120,7 +3158,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(resolved).toBe(false);
|
||||
expect(
|
||||
warn.mock.calls.some(([message]) =>
|
||||
@@ -3800,7 +3840,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
});
|
||||
|
||||
it("times out app-server startup before thread setup can hang forever", async () => {
|
||||
setCodexAppServerClientFactoryForTest(() => new Promise<never>(() => undefined));
|
||||
setCodexAppServerClientFactoryForTest(() => new Promise<never>(() => {}));
|
||||
const params = createParams(
|
||||
path.join(tempDir, "session.jsonl"),
|
||||
path.join(tempDir, "workspace"),
|
||||
@@ -3834,7 +3874,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
interval: 1,
|
||||
});
|
||||
await waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
@@ -4307,7 +4349,7 @@ describe("runCodexAppServerAttempt", () => {
|
||||
const c = {
|
||||
request: vi.fn(async (method: string) => {
|
||||
if (method === "thread/start") {
|
||||
return await new Promise<never>(() => undefined);
|
||||
return await new Promise<never>(() => {});
|
||||
}
|
||||
return {};
|
||||
}),
|
||||
@@ -4502,7 +4544,9 @@ describe("runCodexAppServerAttempt", () => {
|
||||
interval: 1,
|
||||
});
|
||||
await waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
|
||||
@@ -965,8 +965,19 @@ export async function runCodexAppServerAttempt(
|
||||
let client: CodexAppServerClient;
|
||||
let thread: CodexAppServerThreadLifecycleBinding;
|
||||
let trajectoryEndRecorded = false;
|
||||
const markTrajectoryEndRecorded = () => {
|
||||
trajectoryEndRecorded = true;
|
||||
};
|
||||
let nativeHookRelay: NativeHookRelayRegistrationHandle | undefined;
|
||||
let releaseSharedClientLease: (() => void) | undefined;
|
||||
const releaseSharedClientLeaseOnce = () => {
|
||||
const release = releaseSharedClientLease;
|
||||
if (!release) {
|
||||
return;
|
||||
}
|
||||
releaseSharedClientLease = undefined;
|
||||
release();
|
||||
};
|
||||
let sandboxExecEnvironmentAcquired = false;
|
||||
const releaseSandboxExecEnvironment = async () => {
|
||||
if (sandboxExecEnvironmentAcquired) {
|
||||
@@ -1914,7 +1925,7 @@ export async function runCodexAppServerAttempt(
|
||||
aborted: runAbortController.signal.aborted,
|
||||
promptError: turnStartErrorMessage,
|
||||
});
|
||||
trajectoryEndRecorded = true;
|
||||
markTrajectoryEndRecorded();
|
||||
runAgentHarnessLlmOutputHook({
|
||||
event: {
|
||||
runId: params.runId,
|
||||
@@ -1979,8 +1990,7 @@ export async function runCodexAppServerAttempt(
|
||||
},
|
||||
});
|
||||
params.abortSignal?.removeEventListener("abort", abortFromUpstream);
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
releaseSharedClientLeaseOnce();
|
||||
if (usageLimitError) {
|
||||
await markCodexAuthProfileBlockedFromRateLimits({
|
||||
params,
|
||||
@@ -2000,8 +2010,7 @@ export async function runCodexAppServerAttempt(
|
||||
}
|
||||
}
|
||||
if (!turn) {
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
releaseSharedClientLeaseOnce();
|
||||
throw new Error("codex app-server turn/start failed without an error");
|
||||
}
|
||||
turnIdRef.current = turn.turn.id;
|
||||
@@ -2250,7 +2259,7 @@ export async function runCodexAppServerAttempt(
|
||||
yieldDetected,
|
||||
promptError: normalizeCodexTrajectoryError(finalPromptError),
|
||||
});
|
||||
trajectoryEndRecorded = true;
|
||||
markTrajectoryEndRecorded();
|
||||
await mirrorTranscriptBestEffort({
|
||||
params,
|
||||
agentId: sessionAgentId,
|
||||
@@ -2427,7 +2436,7 @@ export async function runCodexAppServerAttempt(
|
||||
notificationCleanup();
|
||||
requestCleanup();
|
||||
closeCleanup?.();
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLeaseOnce();
|
||||
if (nativeHookRelay) {
|
||||
if (shouldDelayNativeHookRelayUnregister) {
|
||||
// Codex hook subprocesses can outlive a completed app-server turn by a
|
||||
|
||||
@@ -71,7 +71,9 @@ describe("createCodexAttemptTurnWatchController", () => {
|
||||
try {
|
||||
controller.armAttemptIdleWatch();
|
||||
controller.touchActivity("turn:start", { attemptProgress: true });
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
controller.noteNotificationReceived("response.output_text.delta", {
|
||||
attemptProgress: true,
|
||||
attemptTimeoutMs: 40,
|
||||
@@ -405,7 +407,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
return turnStartResult("turn-1", "inProgress");
|
||||
}
|
||||
if (method === "turn/interrupt") {
|
||||
return new Promise<never>(() => undefined);
|
||||
return new Promise<never>(() => {});
|
||||
}
|
||||
return {};
|
||||
});
|
||||
@@ -474,7 +476,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
await harness.notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
@@ -488,7 +492,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
await harness.notify({
|
||||
method: "rawResponseItem/completed",
|
||||
params: {
|
||||
@@ -543,7 +549,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
await harness.handleServerRequest({
|
||||
id: "request-account-refresh",
|
||||
method: "account/nonTurnRefresh",
|
||||
@@ -595,7 +603,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
void harness.handleServerRequest({
|
||||
id: "request-auth-refresh",
|
||||
method: "account/chatgptAuthTokens/refresh",
|
||||
@@ -659,7 +669,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
await harness.handleServerRequest({
|
||||
id: "request-null-turn-elicitation",
|
||||
method: "mcpServer/elicitation/request",
|
||||
@@ -673,7 +685,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
_meta: null,
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
@@ -735,7 +749,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
),
|
||||
fastWait,
|
||||
);
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
expect(
|
||||
onRunProgress.mock.calls.some(
|
||||
([event]) =>
|
||||
@@ -788,7 +804,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 75));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 75);
|
||||
});
|
||||
const response = harness.handleServerRequest({
|
||||
id: "request-user-input",
|
||||
method: "item/tool/requestUserInput",
|
||||
@@ -812,7 +830,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1), fastWait);
|
||||
await new Promise((resolve) => setTimeout(resolve, 125));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 125);
|
||||
});
|
||||
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
expect(queueActiveRunMessageForTest("session-1", "2")).toBe(true);
|
||||
@@ -843,7 +863,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
});
|
||||
await harness.waitForMethod("turn/start");
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 60));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 60);
|
||||
});
|
||||
await harness.handleServerRequest({
|
||||
id: "request-foreign-elicitation",
|
||||
method: "mcpServer/elicitation/request",
|
||||
@@ -1052,7 +1074,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
@@ -1158,7 +1182,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
@@ -1258,7 +1284,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
await notify({
|
||||
@@ -1342,7 +1370,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
})) as { success?: boolean };
|
||||
expect(toolResult.success).toBe(false);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 130));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 130);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
@@ -1406,7 +1436,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 130));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 130);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
@@ -1486,7 +1518,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 130));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 130);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
expect(request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
|
||||
@@ -1679,7 +1713,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
const result = await run;
|
||||
@@ -1793,7 +1829,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 40);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
const result = await run;
|
||||
@@ -1884,7 +1922,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
// This covers the future-compatible path for raw response deltas if Codex
|
||||
// app-server exposes them directly; current Codex primarily emits
|
||||
// rawResponseItem/completed for the raw-event surface.
|
||||
@@ -1896,7 +1936,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await notify({
|
||||
@@ -1989,7 +2031,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
await notify({
|
||||
method: "item/fileChange/patchUpdated",
|
||||
params: {
|
||||
@@ -2096,7 +2140,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
@@ -2194,7 +2240,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 40));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 40);
|
||||
});
|
||||
await notify({
|
||||
method: "response.custom_tool_call_input.delta",
|
||||
params: {
|
||||
@@ -2597,7 +2645,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
@@ -2650,7 +2700,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
@@ -2686,7 +2738,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
@@ -2740,7 +2794,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
});
|
||||
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
expect(settled).toBe(false);
|
||||
|
||||
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
@@ -2763,7 +2819,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
const run = runCodexAppServerAttempt(params, { turnCompletionIdleTimeoutMs: 15 });
|
||||
await harness.waitForMethod("turn/start");
|
||||
await harness.notify(rateLimitsUpdated(Date.now() + 60_000));
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
|
||||
const result = await run;
|
||||
expect({
|
||||
@@ -2880,7 +2938,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
|
||||
const queuedTerminal = harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
void queuedTerminal.catch(() => undefined);
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 30);
|
||||
});
|
||||
|
||||
expect(settled).toBe(false);
|
||||
expect(harness.request.mock.calls.some(([method]) => method === "turn/interrupt")).toBe(false);
|
||||
@@ -3191,7 +3251,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
|
||||
expect(request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
|
||||
await notify({
|
||||
@@ -3272,7 +3334,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
|
||||
expect(request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
|
||||
await notify({
|
||||
@@ -3433,7 +3497,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 20));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 20);
|
||||
});
|
||||
|
||||
expect(request).not.toHaveBeenCalledWith("turn/interrupt", expect.anything());
|
||||
await notify({
|
||||
@@ -3677,7 +3743,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
);
|
||||
|
||||
await harness.waitForMethod("turn/start");
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
harness.close();
|
||||
|
||||
const result = await run;
|
||||
@@ -3745,7 +3813,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
|
||||
},
|
||||
},
|
||||
});
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(resolved).toBe(false);
|
||||
|
||||
await harness.notify({
|
||||
|
||||
@@ -206,7 +206,9 @@ export async function waitForHttpBodyDeltas(
|
||||
if (deltas.length >= count) {
|
||||
return deltas;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, 25));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 25);
|
||||
});
|
||||
}
|
||||
throw new Error(`expected ${count} http body deltas`);
|
||||
}
|
||||
|
||||
@@ -704,7 +704,9 @@ describe("shared Codex app-server client", () => {
|
||||
});
|
||||
|
||||
try {
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.once("listening", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected websocket test server port");
|
||||
@@ -741,9 +743,9 @@ describe("shared Codex app-server client", () => {
|
||||
expect(authHeaders).toEqual(["Bearer tok-first", "Bearer tok-second"]);
|
||||
} finally {
|
||||
clearSharedCodexAppServerClient();
|
||||
await new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
);
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -54,9 +54,14 @@ const { testing, runCodexAppServerSideQuestion } = await import("./side-question
|
||||
type ServerRequest = Required<Pick<RpcRequest, "id" | "method">> & {
|
||||
params?: RpcRequest["params"];
|
||||
};
|
||||
type ClientRequest = (
|
||||
method: string,
|
||||
requestParams?: unknown,
|
||||
options?: unknown,
|
||||
) => Promise<unknown>;
|
||||
|
||||
type FakeClient = {
|
||||
request: ReturnType<typeof vi.fn>;
|
||||
request: ReturnType<typeof vi.fn<ClientRequest>>;
|
||||
addNotificationHandler: ReturnType<typeof vi.fn>;
|
||||
addRequestHandler: ReturnType<typeof vi.fn>;
|
||||
notifications: Array<(notification: CodexServerNotification) => void>;
|
||||
@@ -71,7 +76,7 @@ function createFakeClient(): FakeClient {
|
||||
const client: FakeClient = {
|
||||
notifications,
|
||||
requests,
|
||||
request: vi.fn(),
|
||||
request: vi.fn<ClientRequest>(),
|
||||
addNotificationHandler: vi.fn((handler: (notification: CodexServerNotification) => void) => {
|
||||
notifications.push(handler);
|
||||
return () => {
|
||||
@@ -136,7 +141,9 @@ function mockCall(mock: ReturnType<typeof vi.fn>, index = 0): unknown[] {
|
||||
}
|
||||
|
||||
function flushDiagnosticEvents() {
|
||||
return new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function activeDiagnosticToolKeys(events: DiagnosticEventPayload[]): Set<string> {
|
||||
@@ -625,19 +632,21 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
setTimeout(async () => {
|
||||
approvalResponse = await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-side",
|
||||
command: "/bin/bash -lc 'node -v'",
|
||||
cwd: "/tmp/workspace",
|
||||
},
|
||||
});
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
approvalResponse = await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/commandExecution/requestApproval",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
itemId: "cmd-side",
|
||||
command: "/bin/bash -lc 'node -v'",
|
||||
cwd: "/tmp/workspace",
|
||||
},
|
||||
});
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Side answer."));
|
||||
})();
|
||||
}, 0);
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
@@ -913,20 +922,22 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
setTimeout(async () => {
|
||||
toolResponse = await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
callId: "tool-1",
|
||||
tool: "wiki_status",
|
||||
arguments: { topic: "AGENTS.md" },
|
||||
},
|
||||
});
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
toolResponse = await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
callId: "tool-1",
|
||||
tool: "wiki_status",
|
||||
arguments: { topic: "AGENTS.md" },
|
||||
},
|
||||
});
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
|
||||
})();
|
||||
}, 0);
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
@@ -966,20 +977,22 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
setTimeout(async () => {
|
||||
await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
callId: "tool-1",
|
||||
tool: "wiki_status",
|
||||
arguments: { topic: "AGENTS.md" },
|
||||
},
|
||||
});
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
callId: "tool-1",
|
||||
tool: "wiki_status",
|
||||
arguments: { topic: "AGENTS.md" },
|
||||
},
|
||||
});
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
|
||||
})();
|
||||
}, 0);
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
@@ -1045,20 +1058,22 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
setTimeout(async () => {
|
||||
await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
callId: "tool-1",
|
||||
tool: "wiki_status",
|
||||
arguments: { topic: "AGENTS.md" },
|
||||
},
|
||||
});
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/tool/call",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
callId: "tool-1",
|
||||
tool: "wiki_status",
|
||||
arguments: { topic: "AGENTS.md" },
|
||||
},
|
||||
});
|
||||
client.emit(agentDelta("side-thread", "turn-1", "Tool answer."));
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "Tool answer."));
|
||||
})();
|
||||
}, 0);
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
@@ -1098,35 +1113,37 @@ describe("runCodexAppServerSideQuestion", () => {
|
||||
return {};
|
||||
}
|
||||
if (method === "turn/start") {
|
||||
setTimeout(async () => {
|
||||
unrelatedUserInputResponse = await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "parent-thread",
|
||||
turnId: "parent-turn",
|
||||
itemId: "input-parent",
|
||||
questions: [],
|
||||
},
|
||||
});
|
||||
userInputResponse = await client.handleRequest({
|
||||
id: 43,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
itemId: "input-1",
|
||||
questions: [
|
||||
{
|
||||
id: "choice",
|
||||
header: "Choice",
|
||||
question: "Pick one",
|
||||
options: [{ label: "A", description: "" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "No input needed."));
|
||||
setTimeout(() => {
|
||||
void (async () => {
|
||||
unrelatedUserInputResponse = await client.handleRequest({
|
||||
id: 42,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "parent-thread",
|
||||
turnId: "parent-turn",
|
||||
itemId: "input-parent",
|
||||
questions: [],
|
||||
},
|
||||
});
|
||||
userInputResponse = await client.handleRequest({
|
||||
id: 43,
|
||||
method: "item/tool/requestUserInput",
|
||||
params: {
|
||||
threadId: "side-thread",
|
||||
turnId: "turn-1",
|
||||
itemId: "input-1",
|
||||
questions: [
|
||||
{
|
||||
id: "choice",
|
||||
header: "Choice",
|
||||
question: "Pick one",
|
||||
options: [{ label: "A", description: "" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
client.emit(turnCompleted("side-thread", "turn-1", "No input needed."));
|
||||
})();
|
||||
}, 0);
|
||||
return turnStartResult("turn-1");
|
||||
}
|
||||
|
||||
@@ -12,14 +12,12 @@ describe("Codex app-server websocket transport", () => {
|
||||
}
|
||||
clients.length = 0;
|
||||
await Promise.all(
|
||||
servers
|
||||
.splice(0)
|
||||
.map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve())),
|
||||
),
|
||||
),
|
||||
servers.splice(0).map(
|
||||
(server) =>
|
||||
new Promise<void>((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
}),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -42,7 +40,9 @@ describe("Codex app-server websocket transport", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
await new Promise<void>((resolve) => server.once("listening", resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
server.once("listening", resolve);
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === "string") {
|
||||
throw new Error("expected websocket test server port");
|
||||
|
||||
@@ -1096,7 +1096,9 @@ describe("codex conversation binding", () => {
|
||||
},
|
||||
{ timeoutMs: 50 },
|
||||
);
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
handled: true,
|
||||
|
||||
@@ -387,7 +387,9 @@ function isCodexPluginLoadWarningItem(item: MigrationItem): boolean {
|
||||
}
|
||||
|
||||
async function sleep(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
async function buildTargetCodexPluginAppCacheKey(ctx: MigrationProviderContext): Promise<string> {
|
||||
|
||||
@@ -440,7 +440,9 @@ async function waitForLocalHistory(params: {
|
||||
}
|
||||
|
||||
const pollDelayMs = resolveComfyRemainingMs(deadline, params.timeoutMs, params.pollIntervalMs);
|
||||
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, pollDelayMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -479,7 +481,9 @@ async function waitForCloudCompletion(params: {
|
||||
}
|
||||
|
||||
const pollDelayMs = resolveComfyRemainingMs(deadline, params.timeoutMs, params.pollIntervalMs);
|
||||
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, pollDelayMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,9 @@ function createDeferred<T>() {
|
||||
}
|
||||
|
||||
async function flushAsyncWork() {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
|
||||
describe("createCopilotAgentHarness", () => {
|
||||
|
||||
@@ -53,16 +53,17 @@ type SessionEventShape = {
|
||||
timestamp: string;
|
||||
type: string;
|
||||
};
|
||||
type SendAndWaitFn = (options?: unknown) => Promise<SessionEventShape | undefined>;
|
||||
|
||||
type FakeSession = {
|
||||
abort: ReturnType<typeof vi.fn>;
|
||||
abort: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
cfg: Record<string, unknown>;
|
||||
disconnect: ReturnType<typeof vi.fn>;
|
||||
disconnect: ReturnType<typeof vi.fn<() => Promise<void>>>;
|
||||
emit: (eventType: string, data: Record<string, unknown>) => void;
|
||||
id: string;
|
||||
off: ReturnType<typeof vi.fn>;
|
||||
on: ReturnType<typeof vi.fn>;
|
||||
sendAndWait: ReturnType<typeof vi.fn>;
|
||||
sendAndWait: ReturnType<typeof vi.fn<SendAndWaitFn>>;
|
||||
sessionId: string;
|
||||
};
|
||||
|
||||
@@ -129,9 +130,9 @@ function makeAssistantMessageEvent(
|
||||
function createFakeSession(cfg: Record<string, unknown>, id: string): FakeSession {
|
||||
const listeners = new Map<string, Array<(event: SessionEventShape) => void>>();
|
||||
return {
|
||||
abort: vi.fn(async () => undefined),
|
||||
abort: vi.fn<() => Promise<void>>(async () => undefined),
|
||||
cfg,
|
||||
disconnect: vi.fn(async () => undefined),
|
||||
disconnect: vi.fn<() => Promise<void>>(async () => undefined),
|
||||
emit: (eventType: string, data: Record<string, unknown>) => {
|
||||
const event = makeEvent(eventType, data);
|
||||
for (const listener of listeners.get(eventType) ?? []) {
|
||||
@@ -151,7 +152,7 @@ function createFakeSession(cfg: Record<string, unknown>, id: string): FakeSessio
|
||||
handlers.push(handler);
|
||||
listeners.set(eventType, handlers);
|
||||
}),
|
||||
sendAndWait: vi.fn(async () => makeAssistantMessageEvent()),
|
||||
sendAndWait: vi.fn<SendAndWaitFn>(async () => makeAssistantMessageEvent()),
|
||||
sessionId: id,
|
||||
};
|
||||
}
|
||||
@@ -728,7 +729,9 @@ describe("runCopilotAttempt", () => {
|
||||
});
|
||||
const session = await sessionCreated.promise;
|
||||
for (let i = 0; i < 100 && session.sendAndWait.mock.calls.length === 0; i++) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 0));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 0);
|
||||
});
|
||||
}
|
||||
expect(session.sendAndWait).toHaveBeenCalledTimes(1);
|
||||
|
||||
|
||||
@@ -420,7 +420,7 @@ export function convertOpenClawToolToSdkTool(
|
||||
);
|
||||
}
|
||||
|
||||
let preparedArgs = args;
|
||||
let preparedArgs;
|
||||
try {
|
||||
preparedArgs = sourceTool.prepareArguments ? sourceTool.prepareArguments(args) : args;
|
||||
} catch (error: unknown) {
|
||||
|
||||
@@ -346,7 +346,9 @@ async function emitAndCaptureLog(
|
||||
}
|
||||
|
||||
function flushDiagnosticEvents() {
|
||||
return new Promise<void>((resolve) => setImmediate(resolve));
|
||||
return new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
}
|
||||
|
||||
function emitTrustedModelCallCompletedWithContent(
|
||||
@@ -3297,24 +3299,26 @@ describe("diagnostics-otel service", () => {
|
||||
},
|
||||
{
|
||||
inputMessages: [
|
||||
{ role: "user", content: "what changed?", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "toolCall", id: "call-1", name: "lookup", arguments: { q: "trace" } }],
|
||||
},
|
||||
{ role: "toolResult", toolCallId: "call-1", content: { rows: 1 } },
|
||||
],
|
||||
{ role: "user", content: "what changed?", timestamp: 1 },
|
||||
{
|
||||
role: "assistant",
|
||||
content: [
|
||||
{ type: "toolCall", id: "call-1", name: "lookup", arguments: { q: "trace" } },
|
||||
],
|
||||
},
|
||||
{ role: "toolResult", toolCallId: "call-1", content: { rows: 1 } },
|
||||
],
|
||||
outputMessages: [
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "the trace changed" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
],
|
||||
{
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "the trace changed" }],
|
||||
stopReason: "stop",
|
||||
},
|
||||
],
|
||||
systemPrompt: "be exact",
|
||||
toolDefinitions: [
|
||||
{ name: "lookup", description: "Lookup data", parameters: { type: "object" } },
|
||||
],
|
||||
{ name: "lookup", description: "Lookup data", parameters: { type: "object" } },
|
||||
],
|
||||
},
|
||||
);
|
||||
await flushDiagnosticEvents();
|
||||
|
||||
@@ -478,8 +478,8 @@ export function normalizeCompatibilityConfig({
|
||||
}
|
||||
|
||||
const changes: string[] = [];
|
||||
let updated = rawEntry;
|
||||
let changed = false;
|
||||
let updated;
|
||||
let changed;
|
||||
const bindingsToAdd: AgentBindingConfig[] = [];
|
||||
|
||||
const aliases = normalizeLegacyChannelAliases({
|
||||
|
||||
@@ -298,7 +298,7 @@ export function createDiscordAutoPresenceController(params: {
|
||||
let lastAppliedAt = 0;
|
||||
|
||||
const runEvaluation = (options?: { force?: boolean }) => {
|
||||
let decision: DiscordAutoPresenceDecision | null = null;
|
||||
let decision: DiscordAutoPresenceDecision | null;
|
||||
try {
|
||||
decision = resolveDiscordAutoPresenceDecision({
|
||||
discordConfig: params.discordConfig,
|
||||
|
||||
@@ -256,7 +256,7 @@ export function createDiscordDraftPreviewController(params: {
|
||||
);
|
||||
}
|
||||
const alreadyStarted = progressDraftGate.hasStarted;
|
||||
let progressActive = false;
|
||||
let progressActive;
|
||||
if (shouldStartDiscordProgressDraftNow(line)) {
|
||||
await progressDraftGate.startNow();
|
||||
progressActive = progressDraftGate.hasStarted;
|
||||
|
||||
@@ -146,7 +146,7 @@ function copyRuntimeMessageFields(source: Message, target: Message): void {
|
||||
}
|
||||
|
||||
function shouldHydrateDiscordMessage(params: { message: Message }) {
|
||||
let currentText = "";
|
||||
let currentText;
|
||||
try {
|
||||
currentText = resolveDiscordMessageText(params.message, {
|
||||
includeForwarded: true,
|
||||
|
||||
@@ -1154,9 +1154,13 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
vi.useFakeTimers();
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onCompactionStart?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
await params?.replyOptions?.onCompactionEnd?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
|
||||
@@ -1545,7 +1549,9 @@ describe("processDiscordMessage session routing", () => {
|
||||
vi.useFakeTimers();
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onReasoningStream?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
const ctx = await createBaseContext({
|
||||
@@ -1583,7 +1589,9 @@ describe("processDiscordMessage session routing", () => {
|
||||
vi.useFakeTimers();
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.replyOptions?.onReasoningStream?.();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1_000));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 1_000);
|
||||
});
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
const ctx = await createBaseContext({
|
||||
|
||||
@@ -119,7 +119,9 @@ export async function applyDiscordModelPickerSelection(params: {
|
||||
|
||||
const fallbackRoute = dispatchResult.effectiveRoute ?? params.route;
|
||||
if (params.settleMs > 0) {
|
||||
await new Promise((resolve) => setTimeout(resolve, params.settleMs));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, params.settleMs);
|
||||
});
|
||||
}
|
||||
|
||||
let effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
|
||||
@@ -135,7 +137,9 @@ export async function applyDiscordModelPickerSelection(params: {
|
||||
params.selectedModel === params.defaultModel,
|
||||
runtime: params.selectedRuntime,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
|
||||
persisted = effectiveModelRef === params.resolvedModelRef;
|
||||
}
|
||||
@@ -155,7 +159,9 @@ export async function applyDiscordModelPickerSelection(params: {
|
||||
params.selectedModel === params.defaultModel,
|
||||
runtime: params.selectedRuntime,
|
||||
});
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 100);
|
||||
});
|
||||
effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
|
||||
persisted = effectiveModelRef === params.resolvedModelRef;
|
||||
if (!persisted) {
|
||||
|
||||
@@ -336,7 +336,7 @@ export function formatDiscordDeployErrorDetails(err: unknown): string {
|
||||
details.push(`code=${discordCode}`);
|
||||
}
|
||||
if (rawBody !== undefined) {
|
||||
let bodyText = "";
|
||||
let bodyText;
|
||||
try {
|
||||
bodyText = JSON.stringify(rawBody);
|
||||
} catch {
|
||||
|
||||
@@ -440,7 +440,9 @@ describe("createDiscordGatewayPlugin", () => {
|
||||
process.on("unhandledRejection", onUnhandledRejection);
|
||||
try {
|
||||
startIgnoredGatewayRegistration(plugin);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
await new Promise((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
|
||||
expect(unhandledReasons).toHaveLength(0);
|
||||
const registration = waitForDiscordGatewayPluginRegistration(plugin);
|
||||
|
||||
@@ -179,52 +179,52 @@ class DiscordOpusEncodeStream extends Transform {
|
||||
return this.#encoder;
|
||||
}
|
||||
|
||||
override async _transform(
|
||||
chunk: Buffer,
|
||||
_encoding: BufferEncoding,
|
||||
done: TransformCallback,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const encoder = await this.#getEncoder();
|
||||
this.#buffer =
|
||||
this.#buffer.length > 0 ? Buffer.concat([this.#buffer, chunk]) : Buffer.from(chunk);
|
||||
while (this.#buffer.length >= DISCORD_OPUS_FRAME_BYTES) {
|
||||
const frame = this.#buffer.subarray(0, DISCORD_OPUS_FRAME_BYTES);
|
||||
this.#buffer = this.#buffer.subarray(DISCORD_OPUS_FRAME_BYTES);
|
||||
this.push(
|
||||
Buffer.from(
|
||||
encoder.encode(frame, {
|
||||
frameSize: DISCORD_OPUS_FRAME_SIZE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
override _transform(chunk: Buffer, _encoding: BufferEncoding, done: TransformCallback): void {
|
||||
void (async () => {
|
||||
try {
|
||||
const encoder = await this.#getEncoder();
|
||||
this.#buffer =
|
||||
this.#buffer.length > 0 ? Buffer.concat([this.#buffer, chunk]) : Buffer.from(chunk);
|
||||
while (this.#buffer.length >= DISCORD_OPUS_FRAME_BYTES) {
|
||||
const frame = this.#buffer.subarray(0, DISCORD_OPUS_FRAME_BYTES);
|
||||
this.#buffer = this.#buffer.subarray(DISCORD_OPUS_FRAME_BYTES);
|
||||
this.push(
|
||||
Buffer.from(
|
||||
encoder.encode(frame, {
|
||||
frameSize: DISCORD_OPUS_FRAME_SIZE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
|
||||
}
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
override async _final(done: TransformCallback): Promise<void> {
|
||||
try {
|
||||
if (this.#buffer.length > 0) {
|
||||
const encoder = await this.#getEncoder();
|
||||
const frame = Buffer.alloc(DISCORD_OPUS_FRAME_BYTES);
|
||||
this.#buffer.copy(frame);
|
||||
this.#buffer = Buffer.alloc(0);
|
||||
this.push(
|
||||
Buffer.from(
|
||||
encoder.encode(frame, {
|
||||
frameSize: DISCORD_OPUS_FRAME_SIZE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
override _final(done: TransformCallback): void {
|
||||
void (async () => {
|
||||
try {
|
||||
if (this.#buffer.length > 0) {
|
||||
const encoder = await this.#getEncoder();
|
||||
const frame = Buffer.alloc(DISCORD_OPUS_FRAME_BYTES);
|
||||
this.#buffer.copy(frame);
|
||||
this.#buffer = Buffer.alloc(0);
|
||||
this.push(
|
||||
Buffer.from(
|
||||
encoder.encode(frame, {
|
||||
frameSize: DISCORD_OPUS_FRAME_SIZE,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
this.#freeEncoder();
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
|
||||
}
|
||||
this.#freeEncoder();
|
||||
done();
|
||||
} catch (err) {
|
||||
done(err instanceof Error ? err : new Error(formatErrorMessage(err)));
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
override _destroy(err: Error | null, done: (error?: Error | null) => void): void {
|
||||
|
||||
@@ -3865,14 +3865,18 @@ describe("DiscordVoiceManager", () => {
|
||||
|
||||
resolveSecond?.({ payloads: [{ text: "second answer" }] });
|
||||
resolveThird?.({ payloads: [{ text: "third answer" }] });
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expectUserMessageNotIncludes("second answer");
|
||||
expectUserMessageNotIncludes("third answer");
|
||||
|
||||
bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
|
||||
const firstStream = lastAudioResourceInput() as PassThrough | undefined;
|
||||
await vi.waitFor(() => expect(firstStream?.writableEnded).toBe(true));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expectUserMessageNotIncludes("second answer");
|
||||
|
||||
const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as
|
||||
@@ -3886,7 +3890,9 @@ describe("DiscordVoiceManager", () => {
|
||||
bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
|
||||
const secondStream = lastAudioResourceInput() as PassThrough | undefined;
|
||||
await vi.waitFor(() => expect(secondStream?.writableEnded).toBe(true));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expectUserMessageNotIncludes("third answer");
|
||||
|
||||
idleHandler?.();
|
||||
@@ -3950,7 +3956,9 @@ describe("DiscordVoiceManager", () => {
|
||||
bridgeParams?.onEvent?.({ direction: "server", type: "response.done" });
|
||||
const firstStream = lastAudioResourceInput() as PassThrough | undefined;
|
||||
await vi.waitFor(() => expect(firstStream?.writableEnded).toBe(true));
|
||||
await new Promise<void>((resolve) => setImmediate(resolve));
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expectUserMessageNotIncludes("second answer");
|
||||
|
||||
const idleHandler = player.on.mock.calls.find(([event]) => event === "idle")?.[1] as
|
||||
|
||||
@@ -790,34 +790,36 @@ export class DiscordVoiceManager {
|
||||
this.scheduleCaptureFinalize(entry, userId, "speaker end");
|
||||
};
|
||||
|
||||
const disconnectedHandler: (() => Promise<void>) | undefined = async () => {
|
||||
try {
|
||||
logVoiceVerbose(
|
||||
`disconnected: attempting recovery guild ${guildId} channel ${channelId} grace=${reconnectGraceMs}ms`,
|
||||
);
|
||||
await Promise.race([
|
||||
voiceSdk.entersState(
|
||||
connection,
|
||||
voiceSdk.VoiceConnectionStatus.Signalling,
|
||||
reconnectGraceMs,
|
||||
),
|
||||
voiceSdk.entersState(
|
||||
connection,
|
||||
voiceSdk.VoiceConnectionStatus.Connecting,
|
||||
reconnectGraceMs,
|
||||
),
|
||||
]);
|
||||
logVoiceVerbose(`disconnected: recovery started guild ${guildId} channel ${channelId}`);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`,
|
||||
);
|
||||
clearSessionIfCurrent();
|
||||
stopEntry(entry, {
|
||||
destroyConnection: true,
|
||||
reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`,
|
||||
});
|
||||
}
|
||||
const disconnectedHandler: (() => void) | undefined = () => {
|
||||
void (async () => {
|
||||
try {
|
||||
logVoiceVerbose(
|
||||
`disconnected: attempting recovery guild ${guildId} channel ${channelId} grace=${reconnectGraceMs}ms`,
|
||||
);
|
||||
await Promise.race([
|
||||
voiceSdk.entersState(
|
||||
connection,
|
||||
voiceSdk.VoiceConnectionStatus.Signalling,
|
||||
reconnectGraceMs,
|
||||
),
|
||||
voiceSdk.entersState(
|
||||
connection,
|
||||
voiceSdk.VoiceConnectionStatus.Connecting,
|
||||
reconnectGraceMs,
|
||||
),
|
||||
]);
|
||||
logVoiceVerbose(`disconnected: recovery started guild ${guildId} channel ${channelId}`);
|
||||
} catch (err) {
|
||||
logger.warn(
|
||||
`discord voice: disconnect recovery failed: guild ${guildId} channel ${channelId} timeout=${reconnectGraceMs}ms error=${formatErrorMessage(err)}; destroying connection`,
|
||||
);
|
||||
clearSessionIfCurrent();
|
||||
stopEntry(entry, {
|
||||
destroyConnection: true,
|
||||
reason: `disconnect recovery failed guild ${guildId} channel ${channelId}`,
|
||||
});
|
||||
}
|
||||
})();
|
||||
};
|
||||
const destroyedHandler: (() => void) | undefined = () => {
|
||||
clearSessionIfCurrent();
|
||||
|
||||
@@ -538,7 +538,9 @@ async function waitForFalQueueResult(params: {
|
||||
throw new Error(FAL_VIDEO_MALFORMED_RESPONSE);
|
||||
}
|
||||
const pollDelayMs = resolveFalQueueRemainingMs(params.deadline, lastStatus, POLL_INTERVAL_MS);
|
||||
await new Promise((resolve) => setTimeout(resolve, pollDelayMs));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, pollDelayMs);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -18,7 +18,11 @@ export type {
|
||||
ReplyPayload,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
export type { OpenClawConfig as ClawdbotConfig } from "openclaw/plugin-sdk/core";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type RuntimeEnv = {
|
||||
log: (...args: unknown[]) => void;
|
||||
error: (...args: unknown[]) => void;
|
||||
exit: (code: number) => void;
|
||||
};
|
||||
export type { GroupToolPolicyConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
export {
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
|
||||
@@ -340,7 +340,9 @@ export async function getAppOwnerOpenId(params: {
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function sleepRegistrationPollInterval(intervalSeconds: number): Promise<void> {
|
||||
|
||||
@@ -92,7 +92,7 @@ export function resolveFeishuGroupSession(params: {
|
||||
(replyInThread ? messageId : null))
|
||||
: null;
|
||||
|
||||
let peerId = chatId;
|
||||
let peerId;
|
||||
switch (groupSessionScope) {
|
||||
case "group_sender":
|
||||
peerId = buildFeishuConversationId({ chatId, scope: "group_sender", senderOpenId });
|
||||
|
||||
@@ -19,6 +19,7 @@ const { mockCreateFeishuReplyDispatcher, mockCreateFeishuClient, mockResolveAgen
|
||||
},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback: vi.fn(),
|
||||
})),
|
||||
mockCreateFeishuClient: vi.fn(),
|
||||
mockResolveAgentRoute: vi.fn(),
|
||||
@@ -227,6 +228,20 @@ describe("broadcast dispatch", () => {
|
||||
lastRoutePolicy: "session",
|
||||
matchedBy: "default",
|
||||
});
|
||||
mockCreateFeishuReplyDispatcher.mockReturnValue({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(),
|
||||
sendBlockReply: vi.fn(),
|
||||
sendFinalReply: vi.fn(),
|
||||
waitForIdle: vi.fn(),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
markComplete: vi.fn(),
|
||||
},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback: vi.fn(),
|
||||
});
|
||||
mockCreateFeishuClient.mockReturnValue({
|
||||
contact: {
|
||||
user: {
|
||||
@@ -329,6 +344,130 @@ describe("broadcast dispatch", () => {
|
||||
expect(dispatcherParams?.agentId).toBe("main");
|
||||
});
|
||||
|
||||
it("sends no-visible-reply fallback for active broadcast zero-final dispatch", async () => {
|
||||
mockDispatchReplyFromConfig
|
||||
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
queuedFinal: false,
|
||||
counts: { final: 0 },
|
||||
noVisibleReplyFallbackEligible: true,
|
||||
});
|
||||
const ensureNoVisibleReplyFallback = vi.fn();
|
||||
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(),
|
||||
sendBlockReply: vi.fn(),
|
||||
sendFinalReply: vi.fn(),
|
||||
waitForIdle: vi.fn(),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
markComplete: vi.fn(),
|
||||
},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback,
|
||||
});
|
||||
const cfg = createBroadcastConfig();
|
||||
const event = createBroadcastEvent({
|
||||
messageId: "msg-broadcast-zero-final",
|
||||
text: "hello @bot",
|
||||
botMentioned: true,
|
||||
});
|
||||
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId: "bot-open-id",
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith(
|
||||
"broadcast-dispatch-complete-no-visible-reply",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends no-visible-reply fallback for active broadcast failed final delivery", async () => {
|
||||
mockDispatchReplyFromConfig
|
||||
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
queuedFinal: true,
|
||||
counts: { final: 1 },
|
||||
});
|
||||
const ensureNoVisibleReplyFallback = vi.fn();
|
||||
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(),
|
||||
sendBlockReply: vi.fn(),
|
||||
sendFinalReply: vi.fn(),
|
||||
waitForIdle: vi.fn(),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 1 })),
|
||||
markComplete: vi.fn(),
|
||||
},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback,
|
||||
});
|
||||
const cfg = createBroadcastConfig();
|
||||
const event = createBroadcastEvent({
|
||||
messageId: "msg-broadcast-final-failed",
|
||||
text: "hello @bot",
|
||||
botMentioned: true,
|
||||
});
|
||||
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId: "bot-open-id",
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith(
|
||||
"broadcast-dispatch-complete-no-visible-reply",
|
||||
);
|
||||
});
|
||||
|
||||
it("skips no-visible-reply fallback for source-suppressed active broadcast dispatch", async () => {
|
||||
mockDispatchReplyFromConfig
|
||||
.mockResolvedValueOnce({ queuedFinal: false, counts: { final: 1 } })
|
||||
.mockResolvedValueOnce({
|
||||
queuedFinal: false,
|
||||
counts: { final: 0 },
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
noVisibleReplyFallbackEligible: true,
|
||||
});
|
||||
const ensureNoVisibleReplyFallback = vi.fn();
|
||||
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
|
||||
dispatcher: {
|
||||
sendToolResult: vi.fn(),
|
||||
sendBlockReply: vi.fn(),
|
||||
sendFinalReply: vi.fn(),
|
||||
waitForIdle: vi.fn(),
|
||||
getQueuedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
getFailedCounts: vi.fn(() => ({ tool: 0, block: 0, final: 0 })),
|
||||
markComplete: vi.fn(),
|
||||
},
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback,
|
||||
});
|
||||
const cfg = createBroadcastConfig();
|
||||
const event = createBroadcastEvent({
|
||||
messageId: "msg-broadcast-source-suppressed",
|
||||
text: "hello @bot",
|
||||
botMentioned: true,
|
||||
});
|
||||
|
||||
await handleFeishuMessage({
|
||||
cfg,
|
||||
event,
|
||||
botOpenId: "bot-open-id",
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
expect(ensureNoVisibleReplyFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips broadcast dispatch when bot is NOT mentioned (requireMention=true)", async () => {
|
||||
const cfg = createBroadcastConfig();
|
||||
const event = createBroadcastEvent({
|
||||
|
||||
@@ -210,6 +210,7 @@ function createFeishuBotRuntime(overrides: DeepPartial<PluginRuntime> = {}): Plu
|
||||
onRecordError: turn.record?.onRecordError ?? (() => undefined),
|
||||
});
|
||||
return {
|
||||
dispatched: true,
|
||||
dispatchResult: await turn.runDispatch(),
|
||||
};
|
||||
}),
|
||||
@@ -294,6 +295,7 @@ const {
|
||||
dispatcher: createReplyDispatcher(),
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback: vi.fn(),
|
||||
})),
|
||||
mockSendMessageFeishu: vi.fn().mockResolvedValue({ messageId: "pairing-msg", chatId: "oc-dm" }),
|
||||
mockGetMessageFeishu: vi.fn().mockResolvedValue(null),
|
||||
@@ -464,6 +466,7 @@ describe("handleFeishuMessage ACP routing", () => {
|
||||
dispatcher: createReplyDispatcher(),
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback: vi.fn(),
|
||||
});
|
||||
|
||||
setFeishuRuntime(createFeishuBotRuntime());
|
||||
@@ -1046,6 +1049,90 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
expect(mockEnqueueSystemEvent).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not send no-visible fallback when send policy denied delivery", async () => {
|
||||
mockDispatchReplyFromConfig.mockResolvedValueOnce({
|
||||
queuedFinal: false,
|
||||
counts: { tool: 0, block: 0, final: 0 },
|
||||
sendPolicyDenied: true,
|
||||
noVisibleReplyFallbackEligible: true,
|
||||
});
|
||||
const ensureNoVisibleReplyFallback = vi.fn();
|
||||
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
|
||||
dispatcher: createReplyDispatcher(),
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback,
|
||||
});
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig,
|
||||
event: {
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id: "ou-sender",
|
||||
},
|
||||
},
|
||||
message: {
|
||||
message_id: "msg-send-policy-deny",
|
||||
chat_id: "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ensureNoVisibleReplyFallback).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends no-visible fallback when queued final delivery fails", async () => {
|
||||
mockDispatchReplyFromConfig.mockResolvedValueOnce({
|
||||
queuedFinal: true,
|
||||
counts: { tool: 0, block: 0, final: 1 },
|
||||
});
|
||||
const ensureNoVisibleReplyFallback = vi.fn();
|
||||
const dispatcher = createReplyDispatcher();
|
||||
vi.mocked(dispatcher.getFailedCounts).mockReturnValue({ tool: 0, block: 0, final: 1 });
|
||||
mockCreateFeishuReplyDispatcher.mockReturnValueOnce({
|
||||
dispatcher,
|
||||
replyOptions: {},
|
||||
markDispatchIdle: vi.fn(),
|
||||
ensureNoVisibleReplyFallback,
|
||||
});
|
||||
|
||||
await dispatchMessage({
|
||||
cfg: {
|
||||
channels: {
|
||||
feishu: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
} as ClawdbotConfig,
|
||||
event: {
|
||||
sender: {
|
||||
sender_id: {
|
||||
open_id: "ou-sender",
|
||||
},
|
||||
},
|
||||
message: {
|
||||
message_id: "msg-final-delivery-failed",
|
||||
chat_id: "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(ensureNoVisibleReplyFallback).toHaveBeenCalledWith("dispatch-complete-no-visible-reply");
|
||||
});
|
||||
|
||||
it("passes disabled config-write policy to dynamic agent creation", async () => {
|
||||
mockShouldComputeCommandAuthorized.mockReturnValue(false);
|
||||
|
||||
|
||||
@@ -88,6 +88,28 @@ const GROUP_NAME_CACHE_MAX_SIZE = 500; // hard cap
|
||||
|
||||
type FeishuGroupSessionScope = "group" | "group_sender" | "group_topic" | "group_topic_sender";
|
||||
|
||||
function shouldSendNoVisibleReplyFallback(dispatchResult: {
|
||||
counts: { final?: number };
|
||||
failedCounts?: { final?: number };
|
||||
noVisibleReplyFallbackEligible?: boolean;
|
||||
queuedFinal?: boolean;
|
||||
sendPolicyDenied?: boolean;
|
||||
sourceReplyDeliveryMode?: string;
|
||||
}): boolean {
|
||||
const finalCount = dispatchResult.counts.final ?? 0;
|
||||
const failedFinalCount = dispatchResult.failedCounts?.final ?? 0;
|
||||
const emptyEligibleDispatch =
|
||||
dispatchResult.noVisibleReplyFallbackEligible === true &&
|
||||
dispatchResult.queuedFinal !== true &&
|
||||
finalCount === 0;
|
||||
const queuedFinalFailed = dispatchResult.queuedFinal === true && failedFinalCount > 0;
|
||||
return (
|
||||
dispatchResult.sendPolicyDenied !== true &&
|
||||
dispatchResult.sourceReplyDeliveryMode !== "message_tool_only" &&
|
||||
(emptyEligibleDispatch || queuedFinalFailed)
|
||||
);
|
||||
}
|
||||
|
||||
function resolveConfiguredFeishuGroupSessionScope(params: {
|
||||
groupConfig?: {
|
||||
groupSessionScope?: FeishuGroupSessionScope;
|
||||
@@ -1487,26 +1509,28 @@ export async function handleFeishuMessage(params: {
|
||||
if (agentId === activeAgentId) {
|
||||
// Active agent: real Feishu dispatcher (responds on Feishu)
|
||||
const identity = resolveAgentOutboundIdentity(cfg, agentId);
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId,
|
||||
runtime: runtime as RuntimeEnv,
|
||||
chatId: ctx.chatId,
|
||||
allowReasoningPreview,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
skipReplyToInMessages: !isGroup,
|
||||
replyInThread,
|
||||
rootId: ctx.rootId,
|
||||
threadReply,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
messageCreateTimeMs,
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle, ensureNoVisibleReplyFallback } =
|
||||
createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId,
|
||||
runtime: runtime as RuntimeEnv,
|
||||
chatId: ctx.chatId,
|
||||
allowReasoningPreview,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
skipReplyToInMessages: !isGroup,
|
||||
replyInThread,
|
||||
rootId: ctx.rootId,
|
||||
threadReply,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
messageCreateTimeMs,
|
||||
sessionKey: agentSessionKey,
|
||||
});
|
||||
|
||||
log(
|
||||
`feishu[${account.accountId}]: broadcast active dispatch agent=${agentId} (session=${agentSessionKey})`,
|
||||
);
|
||||
await core.channel.inbound.run({
|
||||
const turnResult = await core.channel.inbound.run({
|
||||
channel: "feishu",
|
||||
accountId: route.accountId,
|
||||
raw: ctx,
|
||||
@@ -1547,6 +1571,15 @@ export async function handleFeishuMessage(params: {
|
||||
}),
|
||||
},
|
||||
});
|
||||
if (
|
||||
turnResult.dispatched &&
|
||||
shouldSendNoVisibleReplyFallback({
|
||||
...turnResult.dispatchResult,
|
||||
failedCounts: dispatcher.getFailedCounts(),
|
||||
})
|
||||
) {
|
||||
await ensureNoVisibleReplyFallback("broadcast-dispatch-complete-no-visible-reply");
|
||||
}
|
||||
} else {
|
||||
// Observer agent: no-op dispatcher (session entry + inference, no Feishu reply).
|
||||
// Strip CommandAuthorized so slash commands (e.g. /reset) don't silently
|
||||
@@ -1652,21 +1685,23 @@ export async function handleFeishuMessage(params: {
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle } = createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
runtime: runtime as RuntimeEnv,
|
||||
chatId: ctx.chatId,
|
||||
allowReasoningPreview,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
skipReplyToInMessages: !isGroup,
|
||||
replyInThread,
|
||||
rootId: ctx.rootId,
|
||||
threadReply,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
messageCreateTimeMs,
|
||||
});
|
||||
const { dispatcher, replyOptions, markDispatchIdle, ensureNoVisibleReplyFallback } =
|
||||
createFeishuReplyDispatcher({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
runtime: runtime as RuntimeEnv,
|
||||
chatId: ctx.chatId,
|
||||
allowReasoningPreview,
|
||||
replyToMessageId: replyTargetMessageId,
|
||||
skipReplyToInMessages: !isGroup,
|
||||
replyInThread,
|
||||
rootId: ctx.rootId,
|
||||
threadReply,
|
||||
accountId: account.accountId,
|
||||
identity,
|
||||
messageCreateTimeMs,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
|
||||
log(`feishu[${account.accountId}]: dispatching to agent (session=${route.sessionKey})`);
|
||||
const turnResult = await core.channel.inbound.run({
|
||||
@@ -1733,6 +1768,14 @@ export async function handleFeishuMessage(params: {
|
||||
}
|
||||
const { dispatchResult } = turnResult;
|
||||
const { queuedFinal, counts } = dispatchResult;
|
||||
if (
|
||||
shouldSendNoVisibleReplyFallback({
|
||||
...dispatchResult,
|
||||
failedCounts: dispatcher.getFailedCounts(),
|
||||
})
|
||||
) {
|
||||
await ensureNoVisibleReplyFallback("dispatch-complete-no-visible-reply");
|
||||
}
|
||||
|
||||
log(
|
||||
`feishu[${account.accountId}]: dispatch complete (queuedFinal=${queuedFinal}, replies=${counts.final})`,
|
||||
|
||||
@@ -64,7 +64,7 @@ export const detectFeishuLegacyStateMigrations: BundledChannelLegacyStateMigrati
|
||||
stateDir,
|
||||
}) => {
|
||||
const dedupDir = path.join(stateDir, "feishu", "dedup");
|
||||
let entries: fs.Dirent[] = [];
|
||||
let entries: fs.Dirent[];
|
||||
try {
|
||||
entries = fs.readdirSync(dedupDir, { withFileTypes: true });
|
||||
} catch {
|
||||
|
||||
@@ -400,7 +400,7 @@ function inspectSessionTranscript(params: {
|
||||
return null;
|
||||
}
|
||||
|
||||
let raw = "";
|
||||
let raw;
|
||||
try {
|
||||
raw = fs.readFileSync(params.transcriptPath, "utf-8");
|
||||
} catch {
|
||||
|
||||
@@ -19,11 +19,13 @@ type DispatchReplyContext = Record<string, unknown> & {
|
||||
};
|
||||
type DispatchReplyDispatcher = {
|
||||
sendFinalReply: (payload: { text: string }) => unknown;
|
||||
getFailedCounts?: UnknownMock;
|
||||
};
|
||||
type FeishuReplyDispatcherMockValue = {
|
||||
dispatcher: DispatchReplyDispatcher;
|
||||
replyOptions: Record<string, never>;
|
||||
markDispatchIdle: () => unknown;
|
||||
ensureNoVisibleReplyFallback?: AsyncUnknownMock;
|
||||
};
|
||||
type CreateFeishuReplyDispatcherMock = Mock<(params?: unknown) => FeishuReplyDispatcherMockValue>;
|
||||
type DispatchReplyFromConfigMock = Mock<
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type { RuntimeEnv } from "../runtime-api.js";
|
||||
export {
|
||||
createFixedWindowRateLimiter,
|
||||
createWebhookAnomalyTracker,
|
||||
|
||||
@@ -474,7 +474,7 @@ export async function monitorSingleAccount(params: MonitorSingleAccountParams):
|
||||
log(`feishu[${accountId}]: dedup warmup loaded ${warmupCount} entries from disk`);
|
||||
}
|
||||
|
||||
let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null = null;
|
||||
let threadBindingManager: ReturnType<typeof createFeishuThreadBindingManager> | null | undefined;
|
||||
try {
|
||||
const eventDispatcher = createEventDispatcher(account);
|
||||
const chatHistories = new Map<string, HistoryEntry[]>();
|
||||
|
||||
@@ -361,7 +361,9 @@ async function resolveParsedCommentContent(params: {
|
||||
}
|
||||
|
||||
async function delayMs(ms: number): Promise<void> {
|
||||
await new Promise((resolve) => setTimeout(resolve, ms));
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function buildDriveCommentTargetUrl(params: {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user