mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 08:42:35 +08:00
Compare commits
187 Commits
fix/test-p
...
codex/mess
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d72b96b845 | ||
|
|
19ceca6b55 | ||
|
|
ee79a31ba5 | ||
|
|
4f5e25aa54 | ||
|
|
9512294e8f | ||
|
|
8a7b3c755a | ||
|
|
f8ed4de460 | ||
|
|
b08d901dd2 | ||
|
|
a8f387ba19 | ||
|
|
132d70bfb3 | ||
|
|
009d6b261a | ||
|
|
654544b6b7 | ||
|
|
252673d5b1 | ||
|
|
cc981f8a73 | ||
|
|
c9ddf2eca6 | ||
|
|
73dd758310 | ||
|
|
eadd69b44c | ||
|
|
2a021f3b9b | ||
|
|
78184ea7e4 | ||
|
|
21c8cf9889 | ||
|
|
4e99ec6224 | ||
|
|
a36d29c347 | ||
|
|
cf67d8dded | ||
|
|
31f1ce1af6 | ||
|
|
48853df18c | ||
|
|
3a93d7fd68 | ||
|
|
fcedd37067 | ||
|
|
8bddafba65 | ||
|
|
c24d266b2d | ||
|
|
9405b8f075 | ||
|
|
0feffda3fc | ||
|
|
6343e1483f | ||
|
|
59713194fc | ||
|
|
f1c8cda090 | ||
|
|
a86ca4f4ba | ||
|
|
3bed73f249 | ||
|
|
0dfa22c6e0 | ||
|
|
6f80552ee9 | ||
|
|
258b83c438 | ||
|
|
d095d98a02 | ||
|
|
9ff7abc898 | ||
|
|
dc9c11be91 | ||
|
|
58552f6d7c | ||
|
|
b8811b7dde | ||
|
|
0850d83de1 | ||
|
|
92c10d4edc | ||
|
|
b22ae2a4da | ||
|
|
a822c9abaa | ||
|
|
c308295cd3 | ||
|
|
524e19726f | ||
|
|
bc243568e7 | ||
|
|
2cbb4e70cc | ||
|
|
e9b017d9dc | ||
|
|
bde5be874a | ||
|
|
8c09419f20 | ||
|
|
71f84f910a | ||
|
|
8e6624cb6c | ||
|
|
273eed4c51 | ||
|
|
0bc5fb86a8 | ||
|
|
7bde374c47 | ||
|
|
fa263affd5 | ||
|
|
fa0427347a | ||
|
|
aad78d399c | ||
|
|
928607ac4a | ||
|
|
010c7f7110 | ||
|
|
69891cf2ac | ||
|
|
541f9b25d2 | ||
|
|
c045fbf8ec | ||
|
|
e63d11ea24 | ||
|
|
fed369085f | ||
|
|
6834a2d47b | ||
|
|
52251261ca | ||
|
|
e94deea4f2 | ||
|
|
b827629418 | ||
|
|
02556f9caf | ||
|
|
3f2b205dde | ||
|
|
3d2c52c935 | ||
|
|
e11539234b | ||
|
|
720e295cff | ||
|
|
20d1dc8f0a | ||
|
|
d3ac8e3caa | ||
|
|
93cfd59dd6 | ||
|
|
5078ffdeb4 | ||
|
|
475252453b | ||
|
|
d38fb7456a | ||
|
|
08f8de3aee | ||
|
|
a02a8cca79 | ||
|
|
c638f2beda | ||
|
|
34d2d54d6c | ||
|
|
7cc0879d0e | ||
|
|
2af06042c2 | ||
|
|
8cda4399d0 | ||
|
|
3d6127f7e4 | ||
|
|
72816124c9 | ||
|
|
0e091482a3 | ||
|
|
d51582a936 | ||
|
|
7374ecc777 | ||
|
|
e856a24754 | ||
|
|
9dbdefd43c | ||
|
|
0177521375 | ||
|
|
c714bfd8b6 | ||
|
|
d980f2555a | ||
|
|
300b09b33f | ||
|
|
2429585046 | ||
|
|
a972855150 | ||
|
|
306f0ec37f | ||
|
|
cb6b15f782 | ||
|
|
44d77de0c5 | ||
|
|
bd9f2a5e2e | ||
|
|
b3b210b706 | ||
|
|
536b437454 | ||
|
|
dd055c4f7c | ||
|
|
f484bf9985 | ||
|
|
04575a97b6 | ||
|
|
318f95417a | ||
|
|
1aad7d4e50 | ||
|
|
c313642ae2 | ||
|
|
932b58b94b | ||
|
|
d37300f357 | ||
|
|
9e63323388 | ||
|
|
00f8b10567 | ||
|
|
4dac8f47ed | ||
|
|
9089a8ab32 | ||
|
|
67b26126ce | ||
|
|
307300ac97 | ||
|
|
7e0083ce0b | ||
|
|
740578b596 | ||
|
|
0a986f893a | ||
|
|
9535b102d3 | ||
|
|
1ce8eb3993 | ||
|
|
f354889efa | ||
|
|
cdf35e83f3 | ||
|
|
8a8c6b2a27 | ||
|
|
f5148aff25 | ||
|
|
7bec91c8d8 | ||
|
|
64c81f25c0 | ||
|
|
13ecb5c55e | ||
|
|
db212e572e | ||
|
|
5738cfb6df | ||
|
|
33b8b72ad3 | ||
|
|
e998986889 | ||
|
|
9549545dd0 | ||
|
|
9f0d2427cd | ||
|
|
a59b2f2958 | ||
|
|
c061373ede | ||
|
|
ea0330963c | ||
|
|
43890ebc3b | ||
|
|
e2bcde9b1c | ||
|
|
695cea68f5 | ||
|
|
dd76fdceb6 | ||
|
|
32dc664b4b | ||
|
|
3d8d45fb0d | ||
|
|
d63a73a1b8 | ||
|
|
ca5905eb90 | ||
|
|
023394000c | ||
|
|
a0f93cf88f | ||
|
|
1876e3e1c1 | ||
|
|
6f63140902 | ||
|
|
d0f591893b | ||
|
|
da32c7fe53 | ||
|
|
d3019e6127 | ||
|
|
21d67b168a | ||
|
|
2824c02a42 | ||
|
|
4c9c6f5116 | ||
|
|
90d4aa7a8e | ||
|
|
f826a665a2 | ||
|
|
add9f3c6d3 | ||
|
|
603b250125 | ||
|
|
19ddaa28b9 | ||
|
|
f6b2a5ffb4 | ||
|
|
78a8caef38 | ||
|
|
e0d7776fff | ||
|
|
8efed50c4e | ||
|
|
3e84836b01 | ||
|
|
01abe0a33d | ||
|
|
f0a2ba0584 | ||
|
|
f24b1a9c0c | ||
|
|
7c60379589 | ||
|
|
0fed6402be | ||
|
|
a13e2b92b3 | ||
|
|
e583e62190 | ||
|
|
fe5c098fd7 | ||
|
|
28a5b0a212 | ||
|
|
53f9b6a36b | ||
|
|
eae53595b0 | ||
|
|
ca2f4c0d67 | ||
|
|
f66e83154b |
@@ -146,7 +146,7 @@ Default guidance:
|
||||
|
||||
Default Completeness bands:
|
||||
|
||||
- `Lovable` (95-100): complete across expected workflows, variants, and
|
||||
- `Clawesome` (95-100): complete across expected workflows, variants, and
|
||||
recovery branches, with only minor polish gaps.
|
||||
- `Stable` (80-95): the expected workflow set is broadly present, with only
|
||||
bounded missing branches.
|
||||
@@ -172,7 +172,7 @@ Default Completeness bands:
|
||||
|
||||
Bands:
|
||||
|
||||
- `Lovable`: 95-100
|
||||
- `Clawesome`: 95-100
|
||||
- `Stable`: 80-95
|
||||
- `Beta`: 70-80
|
||||
- `Alpha`: 50-70
|
||||
|
||||
23
.github/codex/prompts/maturity-scorecard-agent.md
vendored
Normal file
23
.github/codex/prompts/maturity-scorecard-agent.md
vendored
Normal file
@@ -0,0 +1,23 @@
|
||||
# OpenClaw Maturity Scorecard Agent
|
||||
|
||||
You are refreshing the OpenClaw maturity score source for a release scorecard.
|
||||
|
||||
Goal: use the `$claw-score` skill to refresh `qa/maturity-scores.yaml` for every active surface in `taxonomy.yaml`, using the current repository and the release evidence artifacts in `.artifacts/maturity-evidence`.
|
||||
|
||||
Allowed tracked paths:
|
||||
|
||||
- `qa/maturity-scores.yaml`
|
||||
|
||||
Hard limits:
|
||||
|
||||
- Do not edit generated docs, taxonomy, workflows, scripts, package metadata, lockfiles, tests, or application code.
|
||||
- Do not render docs. The workflow renders docs after validating the score source.
|
||||
- Keep the score source schema valid for QA Lab maturity score validation.
|
||||
|
||||
Required workflow:
|
||||
|
||||
1. Use the `$claw-score` skill before editing.
|
||||
2. Read `taxonomy.yaml`, any existing maturity score file, and the release evidence artifacts.
|
||||
3. Refresh scores for every active surface in `taxonomy.yaml`.
|
||||
4. Run the QA Lab maturity score validation used by this repository.
|
||||
5. If no defensible score update is possible, leave a valid `qa/maturity-scores.yaml` and explain the uncertainty in the final message.
|
||||
17
.github/workflows/ci-build-artifacts-testbox.yml
vendored
17
.github/workflows/ci-build-artifacts-testbox.yml
vendored
@@ -198,10 +198,19 @@ jobs:
|
||||
"+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
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
|
||||
17
.github/workflows/ci-check-arm-testbox.yml
vendored
17
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -116,10 +116,19 @@ jobs:
|
||||
"+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
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
|
||||
17
.github/workflows/ci-check-testbox.yml
vendored
17
.github/workflows/ci-check-testbox.yml
vendored
@@ -105,10 +105,19 @@ jobs:
|
||||
"+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
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
|
||||
80
.github/workflows/ci.yml
vendored
80
.github/workflows/ci.yml
vendored
@@ -100,6 +100,7 @@ jobs:
|
||||
run_macos_node: ${{ steps.manifest.outputs.run_macos_node }}
|
||||
macos_node_matrix: ${{ steps.manifest.outputs.macos_node_matrix }}
|
||||
run_macos_swift: ${{ steps.manifest.outputs.run_macos_swift }}
|
||||
run_ios_build: ${{ steps.manifest.outputs.run_ios_build }}
|
||||
run_android_job: ${{ steps.manifest.outputs.run_android_job }}
|
||||
android_matrix: ${{ steps.manifest.outputs.android_matrix }}
|
||||
steps:
|
||||
@@ -204,6 +205,7 @@ jobs:
|
||||
OPENCLAW_CI_DOCS_CHANGED: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.docs_scope.outputs.docs_changed }}
|
||||
OPENCLAW_CI_RUN_NODE: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_node || 'false' }}
|
||||
OPENCLAW_CI_RUN_MACOS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_macos || 'false' }}
|
||||
OPENCLAW_CI_RUN_IOS_BUILD: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_ios_build || 'false' }}
|
||||
OPENCLAW_CI_RUN_ANDROID: ${{ github.event_name == 'workflow_dispatch' && (inputs.release_gate || inputs.include_android) && 'true' || steps.changed_scope.outputs.run_android || 'false' }}
|
||||
OPENCLAW_CI_RUN_WINDOWS: ${{ github.event_name == 'workflow_dispatch' && 'true' || steps.changed_scope.outputs.run_windows || 'false' }}
|
||||
OPENCLAW_CI_RUN_NODE_FAST_ONLY: ${{ github.event_name == 'workflow_dispatch' && 'false' || steps.changed_scope.outputs.run_node_fast_only || 'false' }}
|
||||
@@ -267,6 +269,8 @@ jobs:
|
||||
const runPluginContractShards = runNodeFull || runNodeFastPluginContracts;
|
||||
const runMacos =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_MACOS) && !docsOnly && isCanonicalRepository;
|
||||
const runIosBuild =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_IOS_BUILD) && !docsOnly && isCanonicalRepository;
|
||||
const runAndroid =
|
||||
parseBoolean(process.env.OPENCLAW_CI_RUN_ANDROID) && !docsOnly && isCanonicalRepository;
|
||||
const runWindows =
|
||||
@@ -361,6 +365,7 @@ jobs:
|
||||
runMacos ? [{ check_name: "macos-node", runtime: "node", task: "test" }] : [],
|
||||
),
|
||||
run_macos_swift: runMacos,
|
||||
run_ios_build: runIosBuild,
|
||||
run_android_job: runAndroid,
|
||||
android_matrix: createMatrix(
|
||||
runAndroid
|
||||
@@ -1177,7 +1182,9 @@ jobs:
|
||||
timeout-minutes: ${{ matrix.timeout_minutes || 60 }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
max-parallel: 12
|
||||
# The canonical main path waits for the admission debounce above, so
|
||||
# modestly widen this large matrix without recreating registration bursts.
|
||||
max-parallel: 16
|
||||
matrix: ${{ fromJson(needs.preflight.outputs.checks_node_core_nondist_matrix) }}
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -2160,6 +2167,76 @@ jobs:
|
||||
done
|
||||
exit 1
|
||||
|
||||
ios-build:
|
||||
permissions:
|
||||
contents: read
|
||||
name: "ios-build"
|
||||
needs: [preflight]
|
||||
if: needs.preflight.outputs.run_ios_build == 'true'
|
||||
runs-on: ${{ github.event_name == 'workflow_dispatch' && 'macos-26' || (github.repository == 'openclaw/openclaw' && 'blacksmith-12vcpu-macos-26' || 'macos-26') }}
|
||||
timeout-minutes: 45
|
||||
steps:
|
||||
- name: Checkout
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ needs.preflight.outputs.checkout_revision }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git init "$GITHUB_WORKSPACE"
|
||||
git -C "$GITHUB_WORKSPACE" config gc.auto 0
|
||||
git -C "$GITHUB_WORKSPACE" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
fetch_timeout_seconds=90
|
||||
fetch_checkout_ref() {
|
||||
git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" &
|
||||
local fetch_pid="$!"
|
||||
local elapsed=0
|
||||
while kill -0 "$fetch_pid" 2>/dev/null; do
|
||||
if [ "$elapsed" -ge "$fetch_timeout_seconds" ]; then
|
||||
kill -TERM "$fetch_pid" 2>/dev/null || true
|
||||
sleep 10
|
||||
kill -KILL "$fetch_pid" 2>/dev/null || true
|
||||
wait "$fetch_pid" || true
|
||||
return 124
|
||||
fi
|
||||
sleep 1
|
||||
elapsed=$((elapsed + 1))
|
||||
done
|
||||
wait "$fetch_pid"
|
||||
}
|
||||
fetch_checkout_ref
|
||||
git -C "$GITHUB_WORKSPACE" checkout --detach refs/remotes/origin/checkout
|
||||
|
||||
- name: Select Xcode 26
|
||||
run: |
|
||||
set -euo pipefail
|
||||
for xcode_app in /Applications/Xcode_26.5.app /Applications/Xcode-26.5.0.app; do
|
||||
if [ -d "$xcode_app/Contents/Developer" ]; then
|
||||
sudo xcode-select -s "$xcode_app/Contents/Developer"
|
||||
break
|
||||
fi
|
||||
done
|
||||
xcodebuild -version
|
||||
xcode_version="$(xcodebuild -version | awk 'NR == 1 { print $2 }')"
|
||||
if [[ "$xcode_version" != 26.* ]]; then
|
||||
echo "error: expected Xcode 26.x, got $xcode_version" >&2
|
||||
exit 1
|
||||
fi
|
||||
swift --version
|
||||
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
|
||||
- name: Install iOS Swift tooling
|
||||
run: brew install xcodegen swiftlint swiftformat
|
||||
|
||||
- name: Build iOS app
|
||||
run: pnpm ios:build
|
||||
|
||||
android:
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -2311,6 +2388,7 @@ jobs:
|
||||
- checks-windows
|
||||
- macos-node
|
||||
- macos-swift
|
||||
- ios-build
|
||||
- android
|
||||
if: ${{ !cancelled() && always() && github.event_name != 'push' && (github.event_name != 'pull_request' || !github.event.pull_request.draft) }}
|
||||
runs-on: ubuntu-24.04
|
||||
|
||||
18
.github/workflows/codeql.yml
vendored
18
.github/workflows/codeql.yml
vendored
@@ -22,12 +22,6 @@ on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
paths:
|
||||
- ".github/actions/**"
|
||||
- ".github/codeql/**"
|
||||
- ".github/workflows/**"
|
||||
- "packages/**"
|
||||
- "src/**"
|
||||
schedule:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
@@ -55,32 +49,32 @@ jobs:
|
||||
include:
|
||||
- language: javascript-typescript
|
||||
category: core-auth-secrets
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-core-auth-secrets-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: channel-runtime-boundary
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-channel-runtime-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: network-ssrf-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-network-ssrf-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: mcp-process-tool-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-mcp-process-tool-boundary-critical-security.yml
|
||||
- language: javascript-typescript
|
||||
category: plugin-trust-boundary
|
||||
runs_on: blacksmith-4vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 25
|
||||
config_file: ./.github/codeql/codeql-plugin-trust-boundary-critical-security.yml
|
||||
- language: actions
|
||||
category: actions
|
||||
runs_on: blacksmith-8vcpu-ubuntu-2404
|
||||
runs_on: ubuntu-24.04
|
||||
timeout_minutes: 10
|
||||
config_file: ./.github/codeql/codeql-actions-critical-security.yml
|
||||
steps:
|
||||
|
||||
38
.github/workflows/crabbox-hydrate.yml
vendored
38
.github/workflows/crabbox-hydrate.yml
vendored
@@ -171,10 +171,19 @@ jobs:
|
||||
set -euo pipefail
|
||||
|
||||
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
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
@@ -490,7 +499,7 @@ jobs:
|
||||
if (-not $env:CRABBOX_ID -or $env:CRABBOX_ID -notmatch '^[A-Za-z0-9._-]+$') {
|
||||
Write-Error "Invalid crabbox_id"
|
||||
}
|
||||
$actionsRoot = Join-Path $HOME ".crabbox\actions"
|
||||
$actionsRoot = "C:\ProgramData\crabbox\actions"
|
||||
New-Item -ItemType Directory -Force $actionsRoot | Out-Null
|
||||
$state = Join-Path $actionsRoot "$env:CRABBOX_ID.env"
|
||||
$envFile = Join-Path $actionsRoot "$env:CRABBOX_ID.env.ps1"
|
||||
@@ -546,7 +555,7 @@ jobs:
|
||||
if ($env:CRABBOX_KEEP_ALIVE_MINUTES -match '^[0-9]+$') {
|
||||
$minutes = [int]$env:CRABBOX_KEEP_ALIVE_MINUTES
|
||||
}
|
||||
$stop = Join-Path $HOME ".crabbox\actions\$env:CRABBOX_ID.stop"
|
||||
$stop = Join-Path "C:\ProgramData\crabbox\actions" "$env:CRABBOX_ID.stop"
|
||||
$deadline = (Get-Date).AddMinutes($minutes)
|
||||
while ((Get-Date) -lt $deadline) {
|
||||
if (Test-Path $stop) {
|
||||
@@ -584,10 +593,19 @@ jobs:
|
||||
fi
|
||||
|
||||
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
|
||||
link_node_tool() {
|
||||
local tool="$1"
|
||||
local source="$node_bin/$tool"
|
||||
local target="/usr/local/bin/$tool"
|
||||
if [ -e "$target" ] && [ "$(readlink -f "$source")" = "$(readlink -f "$target")" ]; then
|
||||
return
|
||||
fi
|
||||
sudo ln -sf "$source" "$target"
|
||||
}
|
||||
link_node_tool node
|
||||
link_node_tool npm
|
||||
link_node_tool npx
|
||||
link_node_tool corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
|
||||
128
.github/workflows/maturity-scorecard.yml
vendored
128
.github/workflows/maturity-scorecard.yml
vendored
@@ -12,6 +12,40 @@ on:
|
||||
required: true
|
||||
default: main
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
workflow_call:
|
||||
inputs:
|
||||
qa_evidence_run_id:
|
||||
description: Optional workflow run id containing qa-evidence.json
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
ref:
|
||||
description: OpenClaw branch, tag, or SHA containing the maturity score source
|
||||
required: true
|
||||
type: string
|
||||
expected_sha:
|
||||
description: Optional full SHA that ref must resolve to
|
||||
required: false
|
||||
default: ""
|
||||
type: string
|
||||
secrets:
|
||||
OPENAI_API_KEY:
|
||||
description: OpenAI API key used by live QA profile scenarios
|
||||
required: true
|
||||
OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY:
|
||||
description: Optional OpenAI API key used by maturity scorecard agent steps
|
||||
required: false
|
||||
GH_APP_PRIVATE_KEY:
|
||||
description: Optional GitHub App private key for generated docs PR creation
|
||||
required: false
|
||||
GH_APP_PRIVATE_KEY_FALLBACK:
|
||||
description: Optional fallback GitHub App private key for generated docs PR creation
|
||||
required: false
|
||||
|
||||
permissions:
|
||||
actions: read
|
||||
@@ -43,14 +77,25 @@ jobs:
|
||||
- name: Validate selected ref
|
||||
id: validate
|
||||
env:
|
||||
EXPECTED_SHA: ${{ inputs.expected_sha }}
|
||||
INPUT_REF: ${{ inputs.ref }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
selected_revision="$(git rev-parse HEAD)"
|
||||
expected_sha="${EXPECTED_SHA,,}"
|
||||
trusted_reason=""
|
||||
|
||||
if [[ -n "${expected_sha// }" && ! "$expected_sha" =~ ^[0-9a-f]{40}$ ]]; then
|
||||
echo "expected_sha must be a full 40-character SHA; got: ${EXPECTED_SHA}" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${expected_sha// }" && "${selected_revision,,}" != "$expected_sha" ]]; then
|
||||
echo "Ref '${INPUT_REF}' resolved to ${selected_revision}, expected ${EXPECTED_SHA}." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
git fetch --no-tags origin +refs/heads/main:refs/remotes/origin/main
|
||||
|
||||
if git merge-base --is-ancestor "$selected_revision" refs/remotes/origin/main; then
|
||||
@@ -87,8 +132,9 @@ jobs:
|
||||
if: ${{ inputs.qa_evidence_run_id == '' }}
|
||||
uses: ./.github/workflows/qa-profile-evidence.yml
|
||||
with:
|
||||
ref: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
qa_profile: all
|
||||
ref: ${{ inputs.ref }}
|
||||
expected_sha: ${{ needs.validate_selected_ref.outputs.selected_revision }}
|
||||
qa_profile: release
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
@@ -192,8 +238,8 @@ jobs:
|
||||
}
|
||||
|
||||
const evidence = JSON.parse(fs.readFileSync(evidencePath, "utf8"));
|
||||
if (evidence.profile !== "all") {
|
||||
throw new Error(`qa-evidence.json profile must be all, got ${JSON.stringify(evidence.profile)}`);
|
||||
if (evidence.profile !== "release") {
|
||||
throw new Error(`qa-evidence.json profile must be release, got ${JSON.stringify(evidence.profile)}`);
|
||||
}
|
||||
|
||||
const artifactDir = path.dirname(evidencePath);
|
||||
@@ -210,14 +256,75 @@ jobs:
|
||||
const manifestPath = path.join(artifactDir, manifestNames[0]);
|
||||
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
|
||||
const manifestProfile = manifest.qaProfile ?? evidence.profile;
|
||||
if (manifestProfile !== "all") {
|
||||
throw new Error(`QA evidence manifest profile must be all, got ${JSON.stringify(manifestProfile)}`);
|
||||
if (manifestProfile !== "release") {
|
||||
throw new Error(`QA evidence manifest profile must be release, got ${JSON.stringify(manifestProfile)}`);
|
||||
}
|
||||
if (manifest.targetSha !== targetSha) {
|
||||
throw new Error(`QA evidence manifest targetSha ${manifest.targetSha} does not match selected ref ${targetSha}`);
|
||||
}
|
||||
NODE
|
||||
|
||||
- name: Ensure maturity scorecard agent key exists
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ -z "${OPENAI_API_KEY:-}" ]]; then
|
||||
echo "Missing OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY or OPENAI_API_KEY secret." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Run Codex maturity scorecard agent
|
||||
uses: openai/codex-action@e0fdf01220eb9a88167c4898839d273e3f2609d1
|
||||
env:
|
||||
MATURITY_EVIDENCE_DIR: .artifacts/maturity-evidence
|
||||
MATURITY_SCORES_PATH: qa/maturity-scores.yaml
|
||||
MATURITY_TAXONOMY_PATH: taxonomy.yaml
|
||||
with:
|
||||
openai-api-key: ${{ secrets.OPENCLAW_MATURITY_SCORECARD_AGENT_OPENAI_API_KEY || secrets.OPENAI_API_KEY }}
|
||||
prompt-file: .github/codex/prompts/maturity-scorecard-agent.md
|
||||
model: ${{ vars.OPENCLAW_CI_OPENAI_MODEL_BARE }}
|
||||
effort: high
|
||||
sandbox: workspace-write
|
||||
safety-strategy: drop-sudo
|
||||
|
||||
- name: Enforce focused maturity score patch
|
||||
run: |
|
||||
set -euo pipefail
|
||||
git restore --staged :/
|
||||
|
||||
allowed='^qa/maturity-scores\.yaml$'
|
||||
bad_tracked="$(
|
||||
git diff --name-only HEAD -- | while IFS= read -r path; do
|
||||
if [[ ! "$path" =~ $allowed ]]; then
|
||||
printf '%s\n' "$path"
|
||||
fi
|
||||
done
|
||||
)"
|
||||
if [[ -n "$bad_tracked" ]]; then
|
||||
echo "Maturity scorecard agent touched forbidden tracked paths:"
|
||||
printf '%s\n' "$bad_tracked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
bad_untracked="$(
|
||||
git ls-files --others --exclude-standard | while IFS= read -r path; do
|
||||
if [[ "$path" != "qa/maturity-scores.yaml" ]]; then
|
||||
printf '%s\n' "$path"
|
||||
fi
|
||||
done
|
||||
)"
|
||||
if [[ -n "$bad_untracked" ]]; then
|
||||
echo "Maturity scorecard agent created forbidden untracked paths:"
|
||||
printf '%s\n' "$bad_untracked"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ ! -f qa/maturity-scores.yaml ]]; then
|
||||
echo "Maturity scorecard agent must produce qa/maturity-scores.yaml." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Validate maturity score sources
|
||||
run: |
|
||||
node --import tsx --input-type=module <<'NODE'
|
||||
@@ -260,6 +367,7 @@ jobs:
|
||||
--strict-inputs
|
||||
|
||||
- name: Create generated docs PR app token
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
id: app-token
|
||||
continue-on-error: true
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
@@ -270,7 +378,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Create generated docs PR fallback app token
|
||||
if: ${{ steps.app-token.outcome == 'failure' }}
|
||||
if: ${{ github.event_name == 'workflow_dispatch' && steps.app-token.outcome == 'failure' }}
|
||||
id: app-token-fallback
|
||||
uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3
|
||||
with:
|
||||
@@ -280,6 +388,7 @@ jobs:
|
||||
permission-pull-requests: write
|
||||
|
||||
- name: Open generated docs PR
|
||||
if: ${{ github.event_name == 'workflow_dispatch' }}
|
||||
env:
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token || steps.app-token-fallback.outputs.token }}
|
||||
QA_EVIDENCE_RUN_ID: ${{ inputs.qa_evidence_run_id }}
|
||||
@@ -291,7 +400,7 @@ jobs:
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
|
||||
if [[ -z "$(git status --porcelain -- qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md)" ]]; then
|
||||
{
|
||||
echo
|
||||
echo "- Pull request: skipped; generated scorecard matches selected ref"
|
||||
@@ -311,9 +420,6 @@ jobs:
|
||||
git fetch --no-tags --depth=1 origin "refs/heads/${branch}:refs/remotes/origin/${branch}" || true
|
||||
git switch -C "$branch"
|
||||
git add qa/maturity-scores.yaml docs/maturity/scorecard.md docs/maturity/taxonomy.md
|
||||
if git ls-files --error-unmatch docs/maturity-scores.yaml >/dev/null 2>&1 || [[ -e docs/maturity-scores.yaml ]]; then
|
||||
git add docs/maturity-scores.yaml
|
||||
fi
|
||||
git commit -m "docs: update maturity scorecard"
|
||||
git push --force-with-lease origin "$branch"
|
||||
|
||||
|
||||
52
.github/workflows/openclaw-release-checks.yml
vendored
52
.github/workflows/openclaw-release-checks.yml
vendored
@@ -44,6 +44,11 @@ on:
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
run_maturity_scorecard:
|
||||
description: Render advisory maturity scorecard release docs; default release checks rely on dedicated package, QA, live, and E2E gates
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
rerun_group:
|
||||
description: Release check group to run
|
||||
required: false
|
||||
@@ -106,6 +111,7 @@ jobs:
|
||||
mode: ${{ steps.inputs.outputs.mode }}
|
||||
release_profile: ${{ steps.inputs.outputs.release_profile }}
|
||||
run_release_soak: ${{ steps.inputs.outputs.run_release_soak }}
|
||||
run_maturity_scorecard: ${{ steps.inputs.outputs.run_maturity_scorecard }}
|
||||
rerun_group: ${{ steps.inputs.outputs.rerun_group }}
|
||||
live_suite_filter: ${{ steps.inputs.outputs.live_suite_filter }}
|
||||
cross_os_suite_filter: ${{ steps.inputs.outputs.cross_os_suite_filter }}
|
||||
@@ -279,6 +285,7 @@ jobs:
|
||||
RELEASE_MODE_INPUT: ${{ inputs.mode }}
|
||||
RELEASE_PROFILE_INPUT: ${{ inputs.release_profile }}
|
||||
RELEASE_RUN_RELEASE_SOAK_INPUT: ${{ inputs.run_release_soak }}
|
||||
RELEASE_RUN_MATURITY_SCORECARD_INPUT: ${{ inputs.run_maturity_scorecard }}
|
||||
RELEASE_RERUN_GROUP_INPUT: ${{ inputs.rerun_group }}
|
||||
RELEASE_LIVE_SUITE_FILTER_INPUT: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_CROSS_OS_SUITE_FILTER_INPUT: ${{ inputs.cross_os_suite_filter }}
|
||||
@@ -319,6 +326,12 @@ jobs:
|
||||
else
|
||||
run_release_soak=true
|
||||
fi
|
||||
run_maturity_scorecard="$(printf '%s' "$RELEASE_RUN_MATURITY_SCORECARD_INPUT" | tr '[:upper:]' '[:lower:]')"
|
||||
if [[ "$run_maturity_scorecard" != "true" && "$run_maturity_scorecard" != "1" && "$run_maturity_scorecard" != "yes" ]]; then
|
||||
run_maturity_scorecard=false
|
||||
else
|
||||
run_maturity_scorecard=true
|
||||
fi
|
||||
release_profile="$RELEASE_PROFILE_INPUT"
|
||||
if [[ "$release_profile" == "minimum" ]]; then
|
||||
release_profile=beta
|
||||
@@ -422,6 +435,7 @@ jobs:
|
||||
printf 'mode=%s\n' "$RELEASE_MODE_INPUT"
|
||||
printf 'release_profile=%s\n' "$release_profile"
|
||||
printf 'run_release_soak=%s\n' "$run_release_soak"
|
||||
printf 'run_maturity_scorecard=%s\n' "$run_maturity_scorecard"
|
||||
printf 'rerun_group=%s\n' "$RELEASE_RERUN_GROUP_INPUT"
|
||||
printf 'live_suite_filter=%s\n' "$RELEASE_LIVE_SUITE_FILTER_INPUT"
|
||||
printf 'cross_os_suite_filter=%s\n' "$RELEASE_CROSS_OS_SUITE_FILTER_INPUT"
|
||||
@@ -444,6 +458,7 @@ jobs:
|
||||
RELEASE_MODE: ${{ inputs.mode }}
|
||||
RELEASE_PROFILE: ${{ steps.inputs.outputs.release_profile }}
|
||||
RUN_RELEASE_SOAK: ${{ steps.inputs.outputs.run_release_soak }}
|
||||
RUN_MATURITY_SCORECARD: ${{ steps.inputs.outputs.run_maturity_scorecard }}
|
||||
RELEASE_RERUN_GROUP: ${{ inputs.rerun_group }}
|
||||
RELEASE_LIVE_SUITE_FILTER: ${{ inputs.live_suite_filter }}
|
||||
RELEASE_CROSS_OS_SUITE_FILTER: ${{ inputs.cross_os_suite_filter }}
|
||||
@@ -461,6 +476,7 @@ jobs:
|
||||
echo "- Cross-OS mode: \`${RELEASE_MODE}\`"
|
||||
echo "- Release profile: \`${RELEASE_PROFILE}\`"
|
||||
echo "- Release soak lanes: \`${RUN_RELEASE_SOAK}\`"
|
||||
echo "- Maturity scorecard docs: \`${RUN_MATURITY_SCORECARD}\`"
|
||||
echo "- Rerun group: \`${RELEASE_RERUN_GROUP}\`"
|
||||
if [[ -n "${RELEASE_LIVE_SUITE_FILTER// }" ]]; then
|
||||
echo "- Live suite filter: \`${RELEASE_LIVE_SUITE_FILTER}\`"
|
||||
@@ -767,6 +783,20 @@ jobs:
|
||||
OPENCLAW_QA_CONVEX_SITE_URL: ${{ secrets.OPENCLAW_QA_CONVEX_SITE_URL }}
|
||||
OPENCLAW_QA_CONVEX_SECRET_CI: ${{ secrets.OPENCLAW_QA_CONVEX_SECRET_CI }}
|
||||
|
||||
maturity_scorecard_release_checks:
|
||||
name: Render maturity scorecard release docs
|
||||
needs: [resolve_target]
|
||||
if: contains(fromJSON('["all","qa"]'), needs.resolve_target.outputs.rerun_group) && needs.resolve_target.outputs.run_maturity_scorecard == 'true'
|
||||
permissions:
|
||||
actions: read
|
||||
contents: read
|
||||
uses: ./.github/workflows/maturity-scorecard.yml
|
||||
with:
|
||||
ref: ${{ needs.resolve_target.outputs.ref }}
|
||||
expected_sha: ${{ needs.resolve_target.outputs.revision }}
|
||||
secrets:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
|
||||
qa_lab_parity_lane_release_checks:
|
||||
name: Run QA Lab parity lane (${{ matrix.lane }})
|
||||
needs: [resolve_target]
|
||||
@@ -853,7 +883,7 @@ jobs:
|
||||
name: release-qa-parity-${{ matrix.lane }}-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -959,7 +989,7 @@ jobs:
|
||||
name: release-qa-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1131,7 +1161,7 @@ jobs:
|
||||
name: release-qa-runtime-parity-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1241,13 +1271,13 @@ jobs:
|
||||
--output .artifacts/qa-e2e/runtime-parity-standard-report/qa-runtime-tool-coverage-report.md
|
||||
|
||||
- name: Upload runtime tool coverage artifacts
|
||||
if: always()
|
||||
if: ${{ always() && steps.verify_runtime_parity_status.outputs.ready == 'true' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: release-qa-runtime-tool-coverage-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/runtime-parity-standard-report/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
qa_live_matrix_release_checks:
|
||||
name: Run QA Lab live Matrix lane
|
||||
@@ -1327,7 +1357,7 @@ jobs:
|
||||
name: release-qa-live-matrix-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1467,7 +1497,7 @@ jobs:
|
||||
name: release-qa-live-telegram-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1607,7 +1637,7 @@ jobs:
|
||||
name: release-qa-live-discord-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1750,7 +1780,7 @@ jobs:
|
||||
name: release-qa-live-whatsapp-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1890,7 +1920,7 @@ jobs:
|
||||
name: release-qa-live-slack-${{ needs.resolve_target.outputs.revision }}
|
||||
path: .artifacts/qa-e2e/
|
||||
retention-days: 14
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Record advisory status
|
||||
if: always()
|
||||
@@ -1946,6 +1976,7 @@ jobs:
|
||||
- docker_e2e_release_checks
|
||||
- package_acceptance_release_checks
|
||||
- qa_lab_parity_lane_release_checks
|
||||
- maturity_scorecard_release_checks
|
||||
- qa_lab_parity_report_release_checks
|
||||
- qa_lab_runtime_parity_release_checks
|
||||
- runtime_tool_coverage_release_checks
|
||||
@@ -2031,6 +2062,7 @@ jobs:
|
||||
"docker_e2e_release_checks=${{ needs.docker_e2e_release_checks.result }}" \
|
||||
"package_acceptance_release_checks=${{ needs.package_acceptance_release_checks.result }}" \
|
||||
"qa_lab_parity_lane_release_checks=${{ needs.qa_lab_parity_lane_release_checks.result }}" \
|
||||
"maturity_scorecard_release_checks=${{ needs.maturity_scorecard_release_checks.result }}" \
|
||||
"qa_lab_parity_report_release_checks=${{ needs.qa_lab_parity_report_release_checks.result }}" \
|
||||
"qa_lab_runtime_parity_release_checks=${{ needs.qa_lab_runtime_parity_release_checks.result }}" \
|
||||
"runtime_tool_coverage_release_checks=${{ needs.runtime_tool_coverage_release_checks.result }}" \
|
||||
|
||||
@@ -1466,9 +1466,9 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Upload postpublish evidence
|
||||
if: ${{ always() }}
|
||||
if: ${{ always() && inputs.publish_openclaw_npm }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: openclaw-release-postpublish-evidence-${{ inputs.tag }}
|
||||
path: ${{ runner.temp }}/openclaw-release-postpublish-evidence
|
||||
if-no-files-found: ignore
|
||||
if-no-files-found: error
|
||||
|
||||
2
.github/workflows/opengrep-precise-full.yml
vendored
2
.github/workflows/opengrep-precise-full.yml
vendored
@@ -66,5 +66,5 @@ jobs:
|
||||
with:
|
||||
name: opengrep-full-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
2
.github/workflows/opengrep-precise.yml
vendored
2
.github/workflows/opengrep-precise.yml
vendored
@@ -97,5 +97,5 @@ jobs:
|
||||
with:
|
||||
name: opengrep-pr-diff-sarif
|
||||
path: .opengrep-out/precise.sarif
|
||||
if-no-files-found: warn
|
||||
if-no-files-found: error
|
||||
retention-days: 30
|
||||
|
||||
10
.github/workflows/qa-profile-evidence.yml
vendored
10
.github/workflows/qa-profile-evidence.yml
vendored
@@ -89,6 +89,13 @@ jobs:
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
// Reusable workflow jobs inherit the caller event but run as
|
||||
// github-actions[bot]; selected ref validation still gates secrets.
|
||||
if (context.actor === "github-actions[bot]") {
|
||||
core.info("Skipping manual actor permission check for a reusable workflow call.");
|
||||
core.setOutput("authorized", "true");
|
||||
return;
|
||||
}
|
||||
if (context.eventName !== "workflow_dispatch") {
|
||||
core.info(`Skipping manual actor permission check for ${context.eventName}.`);
|
||||
core.setOutput("authorized", "true");
|
||||
@@ -243,6 +250,9 @@ jobs:
|
||||
NODE_OPTIONS: --max-old-space-size=8192
|
||||
run: node scripts/build-all.mjs qaRuntime
|
||||
|
||||
- name: Ensure Playwright Chromium
|
||||
run: node scripts/ensure-playwright-chromium.mjs
|
||||
|
||||
- name: Run QA profile
|
||||
id: run_profile
|
||||
env:
|
||||
|
||||
@@ -37,6 +37,7 @@ This audited record covers the complete v2026.6.8..HEAD history: 423 merged PRs.
|
||||
|
||||
#### Pull requests
|
||||
|
||||
- **PR #92154** fix(qqbot): gate private group commands and close strict command visibility gaps. Thanks @sliverp.
|
||||
- **PR #90463** refactor: add session accessor seam with gateway consumer. Thanks @jalehman.
|
||||
- **PR #88656** Drop reasoning-only length turns from replay. Thanks @abel-zer0.
|
||||
- **PR #92856** feat(webui): add session workspace rail. Thanks @Solvely-Colin.
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
|
||||
## Unreleased
|
||||
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
## 2026.6.9 - 2026-06-23
|
||||
|
||||
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
|
||||
|
||||
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
|
||||
|
||||
## 2026.6.2 - 2026-06-02
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
|
||||
|
||||
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
|
||||
@@ -1 +1,3 @@
|
||||
Maintenance update for the current OpenClaw Android release.
|
||||
Adds settings detail panels, refreshes the Android overview controls, and routes exec approvals into the in-app inbox.
|
||||
|
||||
Improves chat acknowledgement handling, gateway pairing readiness, microphone foreground-service behavior, and release screenshot reliability.
|
||||
|
||||
185
apps/ios/APP-REVIEW-NOTES.md
Normal file
185
apps/ios/APP-REVIEW-NOTES.md
Normal file
@@ -0,0 +1,185 @@
|
||||
# App Review Notes
|
||||
|
||||
Use these steps to exercise the live OpenClaw iOS App Review Gateway.
|
||||
|
||||
## Demo Account / Setup
|
||||
|
||||
Use the OpenClaw iOS app with the live review Gateway setup code included in
|
||||
the `Notes` field of this App Review submission.
|
||||
|
||||
The setup code is a single generated code string. It already contains the public
|
||||
Gateway host and setup credential.
|
||||
|
||||
## Setup Walkthrough
|
||||
|
||||
1. Open the OpenClaw app.
|
||||
2. Tap `Continue`.
|
||||
3. On `Connect Gateway`, tap `Set Up Manually`.
|
||||
4. In the `Setup Code` section, tap the `Paste setup code` field.
|
||||
5. Paste the setup code string from the App Review submission `Notes` field.
|
||||
6. Tap `Apply Setup Code`.
|
||||
7. If `Trust and connect` appears, tap `Trust and connect`.
|
||||
8. Wait for the `Connected` screen.
|
||||
9. On `Connected`, tap `Open OpenClaw`.
|
||||
10. Confirm the `Control` screen shows `Gateway Online`.
|
||||
11. Tap `Settings`.
|
||||
12. Tap `Approvals`.
|
||||
13. Tap `Open Notifications`.
|
||||
14. Tap `Enable Notifications`.
|
||||
15. On `Enable OpenClaw Hosted Push Relay?`, tap `Continue`.
|
||||
16. If iOS asks whether OpenClaw may send notifications, tap `Allow`.
|
||||
17. Confirm `Notifications` shows `Enabled`.
|
||||
|
||||
## Chat
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Tap the text field labeled `Message main...`.
|
||||
3. Send this exact message:
|
||||
|
||||
```text
|
||||
Start Apple review checklist.
|
||||
```
|
||||
|
||||
Expected result: the assistant replies with the available App Review demos.
|
||||
|
||||
## Approval Demo
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Tap the text field labeled `Message main...`.
|
||||
3. Send this exact message:
|
||||
|
||||
```text
|
||||
Run the approval demo.
|
||||
```
|
||||
|
||||
Expected result: the iPhone shows `Exec approval required` with the harmless
|
||||
command `printf 'OpenClaw App Review approval demo complete\n'`. Tap
|
||||
`Allow Once`. The chat then replies:
|
||||
|
||||
```text
|
||||
The approval demo completed.
|
||||
```
|
||||
|
||||
## Talk
|
||||
|
||||
1. Tap the `Talk` tab.
|
||||
2. Tap `Start Talk`.
|
||||
3. If iOS asks for microphone access, tap `Allow`.
|
||||
4. If iOS asks for Speech Recognition access, tap `Allow`.
|
||||
5. Confirm the screen changes to `Ready to talk` and shows `Stop Talk`.
|
||||
6. Say:
|
||||
|
||||
```text
|
||||
Summarize this review setup in one sentence.
|
||||
```
|
||||
|
||||
Expected result: the assistant responds by voice. Tap `Stop Talk` when done.
|
||||
|
||||
## Talk + Background Audio
|
||||
|
||||
1. Tap the `Talk` tab.
|
||||
2. Confirm `Speakerphone` is on.
|
||||
3. Confirm `Background listening` is on.
|
||||
4. Tap `Start Talk`.
|
||||
5. If iOS asks for microphone access, tap `Allow`.
|
||||
6. If iOS asks for Speech Recognition access, tap `Allow`.
|
||||
7. Confirm `Stop Talk` is visible.
|
||||
8. Say:
|
||||
|
||||
```text
|
||||
Tell me when you can hear me.
|
||||
```
|
||||
|
||||
9. While Talk is active, send OpenClaw to the background by returning to the
|
||||
Home Screen or locking the iPhone. Do not force quit the app.
|
||||
10. Continue speaking then wait for assistant audio reply.
|
||||
|
||||
Expected result: realtime Talk audio continues while OpenClaw is backgrounded.
|
||||
Reopen OpenClaw, confirm Talk is still active, then tap `Stop Talk`.
|
||||
|
||||
## Gateway Status
|
||||
|
||||
1. Tap `Control`.
|
||||
2. Tap `Instances`.
|
||||
3. Confirm the screen shows `Gateway online`.
|
||||
4. Confirm at least one `agent` row is connected.
|
||||
5. Confirm the iPhone review device appears in the connected instances list.
|
||||
|
||||
## Push Notification
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Tap the text field labeled `Message main...`.
|
||||
3. Send this exact message:
|
||||
|
||||
```text
|
||||
Start push notification demo.
|
||||
```
|
||||
|
||||
4. Immediately send OpenClaw to the background and lock the iPhone. Do not
|
||||
force quit the app.
|
||||
|
||||
Expected result: the iPhone Lock Screen receives a visible `OpenClaw`
|
||||
notification with this body:
|
||||
|
||||
```text
|
||||
OpenClaw App Review push notification demo
|
||||
```
|
||||
|
||||
Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
|
||||
`Control`, tap `Chat`. Expected chat reply:
|
||||
|
||||
```text
|
||||
The push notification demo completed.
|
||||
```
|
||||
|
||||
## Push Wake / Status
|
||||
|
||||
1. Tap the `Chat` tab.
|
||||
2. Send this exact message:
|
||||
|
||||
```text
|
||||
Start push wake demo.
|
||||
```
|
||||
|
||||
3. Immediately send OpenClaw to the background and lock the iPhone. Do not
|
||||
force quit the app.
|
||||
4. Wait for the `OpenClaw` notification on the Lock Screen. It normally appears
|
||||
about 10 seconds after the message is sent.
|
||||
5. Tap the notification and unlock the iPhone if prompted. If OpenClaw opens on
|
||||
`Control`, tap `Chat`.
|
||||
|
||||
Expected result: the app reconnects to the live Gateway and Chat replies:
|
||||
|
||||
```text
|
||||
The push wake and node status demo completed.
|
||||
```
|
||||
|
||||
## Device Permissions
|
||||
|
||||
1. Tap `Settings`.
|
||||
2. Tap `Permissions`.
|
||||
3. Confirm these current app controls are available:
|
||||
- `Camera`
|
||||
- `Location` with `Off`, `While Using`, and `Always`
|
||||
- `Keep Awake`
|
||||
4. Expand `Privacy & Access`.
|
||||
5. Confirm these request controls are available:
|
||||
- `Contacts` / `Request Access`
|
||||
- `Calendar (Add Events)` / `Request Access`
|
||||
- `Calendar (View Events)` / `Request Full Access`
|
||||
- `Reminders` / `Request Access`
|
||||
|
||||
## Share Sheet
|
||||
|
||||
1. Open Safari.
|
||||
2. Navigate to `https://example.com`.
|
||||
3. Tap the Safari toolbar `More` button.
|
||||
4. Tap `Share`.
|
||||
5. Tap `OpenClaw`.
|
||||
6. Confirm the OpenClaw share extension appears and shows
|
||||
`Edit text, then tap Send.` and `Send to OpenClaw`.
|
||||
7. Tap `Send to OpenClaw`.
|
||||
|
||||
Expected result: the OpenClaw share extension sends the shared Safari page to
|
||||
the live review Gateway and shows `Sent to OpenClaw.` Returning to OpenClaw
|
||||
Chat shows the shared `Example Domain` page.
|
||||
@@ -152,6 +152,7 @@ extension SettingsProTab {
|
||||
}
|
||||
let notificationSettings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
self.applyNotificationStatus(notificationSettings.authorizationStatus)
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
|
||||
let issueCount = SettingsDiagnostics.issueCount(
|
||||
gatewayConnected: self.gatewayDiagnosticConnected,
|
||||
@@ -325,6 +326,7 @@ extension SettingsProTab {
|
||||
self.setupStatusText = "Tailscale is off on this device. Turn it on, then try again."
|
||||
return false
|
||||
}
|
||||
self.gatewayController.requestLocalNetworkAccess(reason: "settings_preflight")
|
||||
self.setupStatusText = "Checking gateway reachability..."
|
||||
let ok = await TCPProbe.probe(host: trimmed, port: port, timeoutSeconds: 3, queueLabel: "gateway.preflight")
|
||||
if !ok {
|
||||
@@ -417,6 +419,7 @@ extension SettingsProTab {
|
||||
let status = settings.authorizationStatus
|
||||
Task { @MainActor in
|
||||
self.applyNotificationStatus(status)
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -437,6 +440,7 @@ extension SettingsProTab {
|
||||
|
||||
func requestNotificationAuthorizationFromSettings() {
|
||||
guard !self.isRequestingNotificationAuthorization else { return }
|
||||
PushEnrollmentConsent.markDisclosureAccepted()
|
||||
self.isRequestingNotificationAuthorization = true
|
||||
Task {
|
||||
let granted = await (try? UNUserNotificationCenter.current().requestAuthorization(options: [
|
||||
@@ -448,12 +452,19 @@ extension SettingsProTab {
|
||||
await MainActor.run {
|
||||
self.isRequestingNotificationAuthorization = false
|
||||
self.notificationStatus = SettingsNotificationStatus(settings.authorizationStatus)
|
||||
guard granted, self.notificationStatus.allowsNotifications else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
guard granted else { return }
|
||||
self.registerForRemoteNotificationsIfEnrollmentReady()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func registerForRemoteNotificationsIfEnrollmentReady() {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else { return }
|
||||
guard self.notificationStatus.allowsNotifications else { return }
|
||||
UIApplication.shared.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func applyNotificationStatus(_ status: UNAuthorizationStatus) {
|
||||
self.notificationStatus = SettingsNotificationStatus(status)
|
||||
|
||||
@@ -127,6 +127,8 @@ final class GatewayConnectionController {
|
||||
private let discovery = GatewayDiscoveryModel()
|
||||
private let discoveryEnabled: Bool
|
||||
private weak var appModel: NodeAppModel?
|
||||
private var localNetworkAccessRequested: Bool
|
||||
private var currentScenePhase: ScenePhase = .inactive
|
||||
private var didAutoConnect = false
|
||||
private var pendingServiceResolvers: [String: GatewayServiceResolver] = [:]
|
||||
private var pendingTrustConnect: PendingTrustConnect?
|
||||
@@ -137,9 +139,14 @@ final class GatewayConnectionController {
|
||||
let useTLS: Bool
|
||||
}
|
||||
|
||||
init(appModel: NodeAppModel, startDiscovery: Bool = true) {
|
||||
init(
|
||||
appModel: NodeAppModel,
|
||||
startDiscovery: Bool = true,
|
||||
deferDiscoveryUntilLocalNetworkRequest: Bool = false)
|
||||
{
|
||||
self.discoveryEnabled = startDiscovery
|
||||
self.appModel = appModel
|
||||
self.localNetworkAccessRequested = !deferDiscoveryUntilLocalNetworkRequest
|
||||
|
||||
GatewaySettingsStore.bootstrapPersistence()
|
||||
let defaults = UserDefaults.standard
|
||||
@@ -148,7 +155,7 @@ final class GatewayConnectionController {
|
||||
self.updateFromDiscovery()
|
||||
self.observeDiscovery()
|
||||
|
||||
if self.discoveryEnabled {
|
||||
if self.discoveryEnabled, self.localNetworkAccessRequested {
|
||||
self.discovery.start()
|
||||
}
|
||||
}
|
||||
@@ -157,11 +164,29 @@ final class GatewayConnectionController {
|
||||
self.discovery.setDebugLoggingEnabled(enabled)
|
||||
}
|
||||
|
||||
func requestLocalNetworkAccess(reason: String) {
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
self.updateFromDiscovery()
|
||||
return
|
||||
}
|
||||
|
||||
self.localNetworkAccessRequested = true
|
||||
GatewayDiagnostics.log("local network access requested reason=\(reason)")
|
||||
|
||||
guard self.currentScenePhase != .background else { return }
|
||||
self.discovery.start()
|
||||
self.updateFromDiscovery()
|
||||
self.attemptAutoReconnectIfNeeded()
|
||||
}
|
||||
|
||||
func setScenePhase(_ phase: ScenePhase) {
|
||||
self.currentScenePhase = phase
|
||||
guard self.discoveryEnabled else {
|
||||
self.discovery.stop()
|
||||
return
|
||||
}
|
||||
guard self.localNetworkAccessRequested else { return }
|
||||
|
||||
switch phase {
|
||||
case .background:
|
||||
@@ -181,6 +206,10 @@ final class GatewayConnectionController {
|
||||
self.updateFromDiscovery()
|
||||
return
|
||||
}
|
||||
guard self.localNetworkAccessRequested else {
|
||||
self.requestLocalNetworkAccess(reason: "restart_discovery")
|
||||
return
|
||||
}
|
||||
|
||||
self.discovery.stop()
|
||||
self.didAutoConnect = false
|
||||
@@ -197,6 +226,7 @@ final class GatewayConnectionController {
|
||||
_ gateway: GatewayDiscoveryModel.DiscoveredGateway,
|
||||
forceReconnect: Bool = false) async -> String?
|
||||
{
|
||||
self.requestLocalNetworkAccess(reason: "connect_discovered_gateway")
|
||||
let instanceId = UserDefaults.standard.string(forKey: "node.instanceId")?
|
||||
.trimmingCharacters(in: .whitespacesAndNewlines) ?? ""
|
||||
if instanceId.isEmpty {
|
||||
@@ -275,6 +305,7 @@ final class GatewayConnectionController {
|
||||
authOverride: ManualAuthOverride? = nil,
|
||||
forceReconnect: Bool = false) async
|
||||
{
|
||||
self.requestLocalNetworkAccess(reason: "connect_manual")
|
||||
let instanceId = GatewaySettingsStore.currentInstanceID()
|
||||
let token =
|
||||
authOverride.map(\.token) ?? GatewaySettingsStore.loadGatewayToken(instanceId: instanceId)
|
||||
@@ -340,6 +371,7 @@ final class GatewayConnectionController {
|
||||
}
|
||||
|
||||
func connectLastKnown() async {
|
||||
self.requestLocalNetworkAccess(reason: "connect_last_known")
|
||||
guard let last = GatewaySettingsStore.loadLastGatewayConnection() else { return }
|
||||
switch last {
|
||||
case let .manual(host, port, useTLS, _):
|
||||
|
||||
@@ -4103,6 +4103,9 @@ extension NodeAppModel {
|
||||
|
||||
private func registerAPNsTokenIfNeeded() async {
|
||||
let usesRelayTransport = await self.pushRegistrationManager.usesRelayTransport
|
||||
guard await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport) else {
|
||||
return
|
||||
}
|
||||
guard self.gatewayConnected else {
|
||||
if usesRelayTransport {
|
||||
GatewayDiagnostics.pushRelay.skipped("gateway_offline")
|
||||
@@ -4163,6 +4166,23 @@ extension NodeAppModel {
|
||||
}
|
||||
}
|
||||
|
||||
private func canPublishAPNsRegistration(usesRelayTransport: Bool) async -> Bool {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else {
|
||||
if usesRelayTransport {
|
||||
GatewayDiagnostics.pushRelay.skipped("enrollment_disclosure_not_accepted")
|
||||
}
|
||||
return false
|
||||
}
|
||||
let status = await self.notificationAuthorizationStatus()
|
||||
guard Self.isNotificationAuthorizationAllowed(status) else {
|
||||
if usesRelayTransport {
|
||||
GatewayDiagnostics.pushRelay.skipped("notifications_not_authorized")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func fetchPushRelayGatewayIdentity() async throws -> PushRelayGatewayIdentity {
|
||||
let response = try await self.operatorGateway.request(
|
||||
method: "gateway.identity.get",
|
||||
@@ -5126,6 +5146,10 @@ extension NodeAppModel {
|
||||
self.setOperatorConnected(connected)
|
||||
}
|
||||
|
||||
func _test_canPublishAPNsRegistration(usesRelayTransport: Bool = true) async -> Bool {
|
||||
await self.canPublishAPNsRegistration(usesRelayTransport: usesRelayTransport)
|
||||
}
|
||||
|
||||
nonisolated static func _test_makeWatchChatItems(from raw: [OpenClawKit.AnyCodable]) -> [OpenClawWatchChatItem] {
|
||||
self.makeWatchChatItems(from: raw)
|
||||
}
|
||||
|
||||
@@ -73,10 +73,16 @@ struct OnboardingWizardView: View {
|
||||
private static let pairingAutoResumeTicker = Timer.publish(every: 2.0, on: .main, in: .common).autoconnect()
|
||||
|
||||
let allowSkip: Bool
|
||||
let onRequestLocalNetworkAccess: (String) -> Void
|
||||
let onClose: () -> Void
|
||||
|
||||
init(allowSkip: Bool, onClose: @escaping () -> Void) {
|
||||
init(
|
||||
allowSkip: Bool,
|
||||
onRequestLocalNetworkAccess: @escaping (String) -> Void,
|
||||
onClose: @escaping () -> Void)
|
||||
{
|
||||
self.allowSkip = allowSkip
|
||||
self.onRequestLocalNetworkAccess = onRequestLocalNetworkAccess
|
||||
self.onClose = onClose
|
||||
_step = State(
|
||||
initialValue: OnboardingStateStore.shouldPresentFirstRunIntro() ? .intro : .welcome)
|
||||
@@ -231,6 +237,7 @@ struct OnboardingWizardView: View {
|
||||
}
|
||||
.onAppear {
|
||||
self.initializeState()
|
||||
self.requestLocalNetworkAccessIfPastIntro(reason: "onboarding_appear")
|
||||
}
|
||||
.onDisappear {
|
||||
self.discoveryRestartTask?.cancel()
|
||||
@@ -864,10 +871,20 @@ extension OnboardingWizardView {
|
||||
|
||||
private func advanceFromIntro() {
|
||||
OnboardingStateStore.markFirstRunIntroSeen()
|
||||
self.requestLocalNetworkAccess(reason: "onboarding_continue")
|
||||
self.statusLine = "In your OpenClaw chat, run /pair qr, then scan the code here."
|
||||
self.step = .welcome
|
||||
}
|
||||
|
||||
private func requestLocalNetworkAccessIfPastIntro(reason: String) {
|
||||
guard self.step != .intro else { return }
|
||||
self.requestLocalNetworkAccess(reason: reason)
|
||||
}
|
||||
|
||||
private func requestLocalNetworkAccess(reason: String) {
|
||||
self.onRequestLocalNetworkAccess(reason)
|
||||
}
|
||||
|
||||
private func navigateBack() {
|
||||
guard let target = self.step.previous else { return }
|
||||
self.connectingGatewayID = nil
|
||||
|
||||
@@ -123,10 +123,30 @@ final class OpenClawAppDelegate: NSObject, UIApplicationDelegate, @preconcurrenc
|
||||
let notificationCenter = UNUserNotificationCenter.current()
|
||||
notificationCenter.delegate = self
|
||||
ExecApprovalNotificationBridge.registerCategory(center: notificationCenter)
|
||||
application.registerForRemoteNotifications()
|
||||
Task { @MainActor in
|
||||
await self.registerForRemoteNotificationsIfEnrollmentReady(application)
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func registerForRemoteNotificationsIfEnrollmentReady(_ application: UIApplication) async {
|
||||
guard PushEnrollmentConsent.disclosureAccepted else { return }
|
||||
guard await Self.isNotificationAuthorizationAllowed() else { return }
|
||||
application.registerForRemoteNotifications()
|
||||
}
|
||||
|
||||
private static func isNotificationAuthorizationAllowed() async -> Bool {
|
||||
let settings = await UNUserNotificationCenter.current().notificationSettings()
|
||||
switch settings.authorizationStatus {
|
||||
case .authorized, .provisional, .ephemeral:
|
||||
return true
|
||||
case .denied, .notDetermined:
|
||||
return false
|
||||
@unknown default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
||||
if let appModel = self.resolvedAppModel() {
|
||||
Task { @MainActor in
|
||||
@@ -626,7 +646,8 @@ struct OpenClawApp: App {
|
||||
_gatewayController = State(
|
||||
initialValue: GatewayConnectionController(
|
||||
appModel: appModel,
|
||||
startDiscovery: !Self.screenshotModeEnabled))
|
||||
startDiscovery: !Self.screenshotModeEnabled,
|
||||
deferDiscoveryUntilLocalNetworkRequest: true))
|
||||
}
|
||||
|
||||
var body: some Scene {
|
||||
|
||||
19
apps/ios/Sources/Push/PushEnrollmentConsent.swift
Normal file
19
apps/ios/Sources/Push/PushEnrollmentConsent.swift
Normal file
@@ -0,0 +1,19 @@
|
||||
import Foundation
|
||||
|
||||
enum PushEnrollmentConsent {
|
||||
static let disclosureAcceptedKey = "push.enrollment.disclosureAccepted"
|
||||
|
||||
static var disclosureAccepted: Bool {
|
||||
UserDefaults.standard.bool(forKey: disclosureAcceptedKey)
|
||||
}
|
||||
|
||||
static func markDisclosureAccepted() {
|
||||
UserDefaults.standard.set(true, forKey: self.disclosureAcceptedKey)
|
||||
}
|
||||
|
||||
#if DEBUG
|
||||
static func reset() {
|
||||
UserDefaults.standard.removeObject(forKey: self.disclosureAcceptedKey)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -683,6 +683,7 @@ struct RootTabs: View {
|
||||
self.updateIdleTimer()
|
||||
self.updateHomeCanvasState()
|
||||
guard newValue == .active else { return }
|
||||
self.maybeRequestLocalNetworkAccess(reason: "scene_active")
|
||||
Task {
|
||||
await self.appModel.refreshGatewayOverviewIfConnected()
|
||||
await MainActor.run {
|
||||
@@ -729,6 +730,10 @@ struct RootTabs: View {
|
||||
.onChange(of: self.onboardingRequestID) { _, _ in
|
||||
self.evaluateOnboardingPresentation(force: true)
|
||||
}
|
||||
.onChange(of: self.showOnboarding) { _, newValue in
|
||||
guard !newValue else { return }
|
||||
self.maybeRequestLocalNetworkAccess(reason: "onboarding_dismissed")
|
||||
}
|
||||
.onChange(of: self.appModel.openChatRequestID) { _, _ in
|
||||
self.selectSidebarDestination(.chat)
|
||||
}
|
||||
@@ -767,6 +772,9 @@ struct RootTabs: View {
|
||||
.fullScreenCover(isPresented: self.$showOnboarding) {
|
||||
OnboardingWizardView(
|
||||
allowSkip: self.onboardingAllowSkip,
|
||||
onRequestLocalNetworkAccess: { reason in
|
||||
self.requestLocalNetworkAccess(reason: reason)
|
||||
},
|
||||
onClose: {
|
||||
self.showOnboarding = false
|
||||
})
|
||||
@@ -1045,13 +1053,14 @@ extension RootTabs {
|
||||
shouldPresentOnLaunch: OnboardingStateStore.shouldPresentOnLaunch(appModel: self.appModel))
|
||||
switch route {
|
||||
case .none:
|
||||
break
|
||||
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
|
||||
case .onboarding:
|
||||
self.onboardingAllowSkip = true
|
||||
self.showOnboarding = true
|
||||
case .settings:
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.maybeRequestLocalNetworkAccess(reason: "root_appear")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1078,6 +1087,7 @@ extension RootTabs {
|
||||
guard route == .settings else { return }
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.maybeRequestLocalNetworkAccess(reason: "auto_open_settings")
|
||||
}
|
||||
|
||||
private func maybeOpenSettingsForGatewaySetup() {
|
||||
@@ -1088,6 +1098,19 @@ extension RootTabs {
|
||||
self.presentedSheet = nil
|
||||
self.didAutoOpenSettings = true
|
||||
self.selectSidebarDestination(.gateway)
|
||||
self.requestLocalNetworkAccess(reason: "gateway_setup_deeplink")
|
||||
}
|
||||
|
||||
private func maybeRequestLocalNetworkAccess(reason: String) {
|
||||
guard self.didEvaluateOnboarding else { return }
|
||||
guard self.scenePhase == .active else { return }
|
||||
guard !self.showOnboarding else { return }
|
||||
self.requestLocalNetworkAccess(reason: reason)
|
||||
}
|
||||
|
||||
private func requestLocalNetworkAccess(reason: String) {
|
||||
guard !self.appModel.isAppleReviewDemoModeEnabled else { return }
|
||||
self.gatewayController.requestLocalNetworkAccess(reason: reason)
|
||||
}
|
||||
|
||||
private func applyInitialChatSessionIfNeeded() {
|
||||
|
||||
@@ -76,6 +76,7 @@ Sources/Permissions/PermissionRequestBridge.swift
|
||||
Sources/Push/ExecApprovalNotificationBridge.swift
|
||||
Sources/Push/BackgroundAliveBeacon.swift
|
||||
Sources/Push/PushBuildConfig.swift
|
||||
Sources/Push/PushEnrollmentConsent.swift
|
||||
Sources/Push/PushRegistrationManager.swift
|
||||
Sources/Push/PushRelayClient.swift
|
||||
Sources/Push/PushRelayKeychainStore.swift
|
||||
|
||||
@@ -1377,6 +1377,24 @@ private final class MockBootstrapNotificationCenter: NotificationCentering, @unc
|
||||
#expect(center.addCalls == 1)
|
||||
}
|
||||
|
||||
@Test @MainActor func apnsRegistrationRequiresDisclosureAndNotificationAuthorization() async {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .authorized
|
||||
let appModel = NodeAppModel(notificationCenter: center)
|
||||
PushEnrollmentConsent.reset()
|
||||
defer { PushEnrollmentConsent.reset() }
|
||||
|
||||
#expect(await appModel._test_canPublishAPNsRegistration() == false)
|
||||
#expect(await appModel._test_canPublishAPNsRegistration(usesRelayTransport: false) == false)
|
||||
|
||||
PushEnrollmentConsent.markDisclosureAccepted()
|
||||
center.status = .notDetermined
|
||||
#expect(await appModel._test_canPublishAPNsRegistration() == false)
|
||||
|
||||
center.status = .authorized
|
||||
#expect(await appModel._test_canPublishAPNsRegistration())
|
||||
}
|
||||
|
||||
@Test @MainActor func chatPushWithoutSpeechReturnsUnavailableWhenNotificationsOff() async throws {
|
||||
let center = MockBootstrapNotificationCenter()
|
||||
center.status = .notDetermined
|
||||
|
||||
@@ -550,6 +550,20 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(docsSource.contains(".accessibilityHint(\"Opens Settings / Gateway\")"))
|
||||
}
|
||||
|
||||
@Test func `push enrollment stays behind notification disclosure flow`() throws {
|
||||
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let modelSource = try String(contentsOf: Self.nodeAppModelSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(appSource.contains("PushEnrollmentConsent.disclosureAccepted"))
|
||||
#expect(appSource.contains("await Self.isNotificationAuthorizationAllowed()"))
|
||||
#expect(actionsSource.contains("PushEnrollmentConsent.markDisclosureAccepted()"))
|
||||
#expect(actionsSource.contains("self.registerForRemoteNotificationsIfEnrollmentReady()"))
|
||||
#expect(modelSource.contains("PushEnrollmentConsent.disclosureAccepted"))
|
||||
#expect(modelSource.contains("notifications_not_authorized"))
|
||||
#expect(modelSource.contains("enrollment_disclosure_not_accepted"))
|
||||
}
|
||||
|
||||
@Test func `gateway settings keeps pairing trust diagnostics and tailscale actions`() throws {
|
||||
let settingsSource = try String(contentsOf: Self.settingsProTabSourceURL(), encoding: .utf8)
|
||||
let sectionsSource = try String(contentsOf: Self.settingsProTabSectionsSourceURL(), encoding: .utf8)
|
||||
@@ -580,6 +594,7 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(actionsSource.contains("self.gatewayController.refreshActiveGatewayRegistrationFromSettings()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.restartDiscovery()"))
|
||||
#expect(actionsSource.contains("await self.appModel.refreshGatewayOverviewIfConnected()"))
|
||||
#expect(actionsSource.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
|
||||
#expect(actionsSource.contains("await TCPProbe.probe(host: trimmed, port: port"))
|
||||
#expect(actionsSource.contains("Check Tailscale or LAN."))
|
||||
#expect(actionsSource.contains("Tailscale is off on this device. Turn it on, then try again."))
|
||||
@@ -596,6 +611,32 @@ struct RootTabsSourceGuardTests {
|
||||
#expect(controllerSource.contains("trustRotatedGatewayCertificate(from problem: GatewayConnectionProblem)"))
|
||||
}
|
||||
|
||||
@Test func `local network access is requested from visible gateway flows`() throws {
|
||||
let appSource = try String(contentsOf: Self.openClawAppSourceURL(), encoding: .utf8)
|
||||
let rootSource = try String(contentsOf: Self.rootTabsSourceURL(), encoding: .utf8)
|
||||
let onboardingSource = try String(contentsOf: Self.onboardingWizardSourceURL(), encoding: .utf8)
|
||||
let actionsSource = try String(contentsOf: Self.settingsProTabActionsSourceURL(), encoding: .utf8)
|
||||
let controllerSource = try String(contentsOf: Self.gatewayConnectionControllerSourceURL(), encoding: .utf8)
|
||||
|
||||
#expect(appSource.contains("deferDiscoveryUntilLocalNetworkRequest: true"))
|
||||
#expect(controllerSource.contains("func requestLocalNetworkAccess(reason: String)"))
|
||||
#expect(controllerSource.contains("guard self.localNetworkAccessRequested else"))
|
||||
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_manual\")"))
|
||||
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_discovered_gateway\")"))
|
||||
#expect(controllerSource.contains("self.requestLocalNetworkAccess(reason: \"connect_last_known\")"))
|
||||
|
||||
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"root_appear\")"))
|
||||
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"scene_active\")"))
|
||||
#expect(rootSource.contains("self.maybeRequestLocalNetworkAccess(reason: \"onboarding_dismissed\")"))
|
||||
#expect(rootSource.contains("self.requestLocalNetworkAccess(reason: \"gateway_setup_deeplink\")"))
|
||||
#expect(rootSource.contains("guard self.didEvaluateOnboarding else { return }"))
|
||||
#expect(rootSource.contains("onRequestLocalNetworkAccess: { reason in"))
|
||||
|
||||
#expect(onboardingSource.contains("self.requestLocalNetworkAccess(reason: \"onboarding_continue\")"))
|
||||
#expect(onboardingSource.contains("self.requestLocalNetworkAccessIfPastIntro(reason: \"onboarding_appear\")"))
|
||||
#expect(actionsSource.contains("self.gatewayController.requestLocalNetworkAccess(reason: \"settings_preflight\")"))
|
||||
}
|
||||
|
||||
@Test func `gateway settings preview matrix covers primary states`() throws {
|
||||
let supportSource = try String(contentsOf: Self.settingsProTabSupportSourceURL(), encoding: .utf8)
|
||||
|
||||
@@ -786,6 +827,20 @@ struct RootTabsSourceGuardTests {
|
||||
.appendingPathComponent("Sources/Design/SettingsProTab.swift")
|
||||
}
|
||||
|
||||
private static func onboardingWizardSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/Onboarding/OnboardingWizardView.swift")
|
||||
}
|
||||
|
||||
private static func openClawAppSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
.deletingLastPathComponent()
|
||||
.appendingPathComponent("Sources/OpenClawApp.swift")
|
||||
}
|
||||
|
||||
private static func notificationPermissionGuidanceDialogSourceURL() -> URL {
|
||||
URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent()
|
||||
|
||||
@@ -15,13 +15,12 @@
|
||||
import Foundation
|
||||
import XCTest
|
||||
|
||||
var deviceLanguage = ""
|
||||
var locale = ""
|
||||
|
||||
@MainActor
|
||||
func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
if waitForLoadingIndicator {
|
||||
Snapshot.snapshot(name)
|
||||
@@ -33,6 +32,7 @@ func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
|
||||
/// - Parameters:
|
||||
/// - name: The name of the snapshot
|
||||
/// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
|
||||
@MainActor
|
||||
func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
|
||||
Snapshot.snapshot(name, timeWaitingForIdle: timeout)
|
||||
}
|
||||
@@ -52,6 +52,7 @@ enum SnapshotError: Error, CustomDebugStringConvertible {
|
||||
}
|
||||
|
||||
@objcMembers
|
||||
@MainActor
|
||||
open class Snapshot: NSObject {
|
||||
static var app: XCUIApplication?
|
||||
static var waitForAnimations = true
|
||||
@@ -59,6 +60,8 @@ open class Snapshot: NSObject {
|
||||
static var screenshotsDirectory: URL? {
|
||||
return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
|
||||
}
|
||||
static var deviceLanguage = ""
|
||||
static var currentLocale = ""
|
||||
|
||||
open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
|
||||
|
||||
@@ -103,17 +106,17 @@ open class Snapshot: NSObject {
|
||||
|
||||
do {
|
||||
let trimCharacterSet = CharacterSet.whitespacesAndNewlines
|
||||
locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
currentLocale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
|
||||
} catch {
|
||||
NSLog("Couldn't detect/set locale...")
|
||||
}
|
||||
|
||||
if locale.isEmpty && !deviceLanguage.isEmpty {
|
||||
locale = Locale(identifier: deviceLanguage).identifier
|
||||
if currentLocale.isEmpty && !deviceLanguage.isEmpty {
|
||||
currentLocale = Locale(identifier: deviceLanguage).identifier
|
||||
}
|
||||
|
||||
if !locale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
|
||||
if !currentLocale.isEmpty {
|
||||
app.launchArguments += ["-AppleLocale", "\"\(currentLocale)\""]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -165,7 +168,7 @@ open class Snapshot: NSObject {
|
||||
}
|
||||
|
||||
let screenshot = XCUIScreen.main.screenshot()
|
||||
#if os(iOS)
|
||||
#if os(iOS) && !targetEnvironment(macCatalyst)
|
||||
let image = XCUIDevice.shared.orientation.isLandscape ? fixLandscapeOrientation(image: screenshot.image) : screenshot.image
|
||||
#else
|
||||
let image = screenshot.image
|
||||
@@ -181,7 +184,7 @@ open class Snapshot: NSObject {
|
||||
|
||||
let path = screenshotsDir.appendingPathComponent("\(simulator)-\(name).png")
|
||||
#if swift(<5.0)
|
||||
UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
try UIImagePNGRepresentation(image)?.write(to: path, options: .atomic)
|
||||
#else
|
||||
try image.pngData()?.write(to: path, options: .atomic)
|
||||
#endif
|
||||
@@ -281,6 +284,7 @@ private extension XCUIElementQuery {
|
||||
return self.containing(isNetworkLoadingIndicator)
|
||||
}
|
||||
|
||||
@MainActor
|
||||
var deviceStatusBars: XCUIElementQuery {
|
||||
guard let app = Snapshot.app else {
|
||||
fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
|
||||
@@ -306,4 +310,4 @@ private extension CGFloat {
|
||||
|
||||
// Please don't remove the lines below
|
||||
// They are used to detect outdated configuration files
|
||||
// SnapshotHelperVersion [1.27]
|
||||
// SnapshotHelperVersion [1.30]
|
||||
|
||||
@@ -10,7 +10,24 @@ default_platform(:ios)
|
||||
|
||||
APP_STORE_APP_IDENTIFIER = "ai.openclawfoundation.app"
|
||||
DEFAULT_APP_STORE_CONNECT_KEYCHAIN_SERVICE = "openclaw-app-store-connect-key"
|
||||
DEFAULT_SNAPSHOT_DEVICES = ["iPhone 16 Pro Max", "iPad Pro 13-inch (M4)"].freeze
|
||||
DEFAULT_SNAPSHOT_DEVICE_FAMILIES = [
|
||||
{
|
||||
label: "iPhone",
|
||||
patterns: [
|
||||
/\AiPhone .* Pro Max\z/,
|
||||
/\AiPhone .* Plus\z/,
|
||||
/\AiPhone .*\z/
|
||||
]
|
||||
},
|
||||
{
|
||||
label: "13-inch iPad",
|
||||
patterns: [
|
||||
/\AiPad Pro 13-inch/,
|
||||
/\AiPad Air 13-inch/,
|
||||
/\AiPad .*13-inch/
|
||||
]
|
||||
}
|
||||
].freeze
|
||||
DEFAULT_WATCH_SNAPSHOT_DEVICE = "Apple Watch Ultra 3 (49mm)"
|
||||
WATCH_SCREENSHOT_MODE_DEFAULTS_KEY = "openclaw.watch.screenshotMode"
|
||||
WATCH_SNAPSHOT_STATUS_BAR_TIME = "09:41"
|
||||
@@ -77,11 +94,23 @@ end
|
||||
|
||||
def snapshot_devices
|
||||
raw = ENV["OPENCLAW_SNAPSHOT_DEVICES"].to_s.strip
|
||||
return DEFAULT_SNAPSHOT_DEVICES if raw.empty?
|
||||
return default_snapshot_devices if raw.empty?
|
||||
|
||||
raw.split(",").map(&:strip).reject(&:empty?)
|
||||
end
|
||||
|
||||
def default_snapshot_devices
|
||||
names = available_simulator_devices.map { |device| device["name"].to_s }.reject(&:empty?).uniq
|
||||
|
||||
DEFAULT_SNAPSHOT_DEVICE_FAMILIES.map do |family|
|
||||
match = family.fetch(:patterns).filter_map do |pattern|
|
||||
names.find { |name| name.match?(pattern) }
|
||||
end.first
|
||||
UI.user_error!("No available #{family.fetch(:label)} simulator found for App Store screenshots.") if match.nil?
|
||||
match
|
||||
end
|
||||
end
|
||||
|
||||
def watch_snapshot_device
|
||||
raw = ENV["OPENCLAW_WATCH_SNAPSHOT_DEVICE"].to_s.strip
|
||||
raw.empty? ? DEFAULT_WATCH_SNAPSHOT_DEVICE : raw
|
||||
@@ -113,6 +142,51 @@ def resolve_simulator_device(name)
|
||||
fallback
|
||||
end
|
||||
|
||||
def install_ready_for_review_edit_state_lookup!
|
||||
require "spaceship"
|
||||
|
||||
app_class = Spaceship::ConnectAPI::App
|
||||
app_class.class_eval do
|
||||
unless method_defined?(:openclaw_get_edit_app_store_version_without_ready_for_review)
|
||||
alias_method :openclaw_get_edit_app_store_version_without_ready_for_review, :get_edit_app_store_version
|
||||
end
|
||||
|
||||
unless method_defined?(:openclaw_fetch_edit_app_info_without_ready_for_review)
|
||||
alias_method :openclaw_fetch_edit_app_info_without_ready_for_review, :fetch_edit_app_info
|
||||
end
|
||||
|
||||
def get_edit_app_store_version(client: nil, platform: nil, includes: Spaceship::ConnectAPI::AppStoreVersion::ESSENTIAL_INCLUDES)
|
||||
version = openclaw_get_edit_app_store_version_without_ready_for_review(client: client, platform: platform, includes: includes)
|
||||
return version if version
|
||||
|
||||
# First public releases can leave the only version in READY_FOR_REVIEW.
|
||||
# Fastlane 2.236.1 excludes that state and then tries to create an illegal
|
||||
# second version; use the existing review-ready version as the edit target.
|
||||
client ||= Spaceship::ConnectAPI
|
||||
platform ||= Spaceship::ConnectAPI::Platform::IOS
|
||||
filter = {
|
||||
appVersionState: Spaceship::ConnectAPI::AppStoreVersion::AppVersionState::READY_FOR_REVIEW,
|
||||
platform: platform
|
||||
}
|
||||
|
||||
get_app_store_versions(client: client, filter: filter, includes: includes)
|
||||
.sort_by { |candidate| Gem::Version.new(candidate.version_string) }
|
||||
.last
|
||||
end
|
||||
|
||||
def fetch_edit_app_info(client: nil, includes: Spaceship::ConnectAPI::AppInfo::ESSENTIAL_INCLUDES)
|
||||
app_info = openclaw_fetch_edit_app_info_without_ready_for_review(client: client, includes: includes)
|
||||
return app_info if app_info
|
||||
|
||||
client ||= Spaceship::ConnectAPI
|
||||
client
|
||||
.get_app_infos(app_id: id, includes: includes)
|
||||
.to_models
|
||||
.find { |candidate| candidate.state == Spaceship::ConnectAPI::AppInfo::State::READY_FOR_REVIEW }
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def bundle_identifier_for_product(product_path)
|
||||
info_plist_path = File.join(product_path, "Info.plist")
|
||||
UI.user_error!("Expected Info.plist at #{info_plist_path}.") unless File.exist?(info_plist_path)
|
||||
@@ -210,6 +284,7 @@ def normalize_watch_screenshot_status_bar(path)
|
||||
script = <<~SWIFT
|
||||
import AppKit
|
||||
import Foundation
|
||||
import ImageIO
|
||||
|
||||
let path = CommandLine.arguments[1]
|
||||
let timeText = CommandLine.arguments[2]
|
||||
@@ -221,36 +296,37 @@ def normalize_watch_screenshot_status_bar(path)
|
||||
exit(2)
|
||||
}
|
||||
|
||||
let width = CGFloat(cgImage.width)
|
||||
let height = CGFloat(cgImage.height)
|
||||
guard let bitmap = NSBitmapImageRep(
|
||||
bitmapDataPlanes: nil,
|
||||
pixelsWide: Int(width),
|
||||
pixelsHigh: Int(height),
|
||||
bitsPerSample: 8,
|
||||
samplesPerPixel: 4,
|
||||
hasAlpha: true,
|
||||
isPlanar: false,
|
||||
colorSpaceName: .deviceRGB,
|
||||
bytesPerRow: 0,
|
||||
bitsPerPixel: 0),
|
||||
let graphicsContext = NSGraphicsContext(bitmapImageRep: bitmap)
|
||||
let width = cgImage.width
|
||||
let height = cgImage.height
|
||||
let drawWidth = CGFloat(width)
|
||||
let drawHeight = CGFloat(height)
|
||||
let colorSpace = CGColorSpace(name: CGColorSpace.sRGB) ?? CGColorSpaceCreateDeviceRGB()
|
||||
guard let bitmapContext = CGContext(
|
||||
data: nil,
|
||||
width: width,
|
||||
height: height,
|
||||
bitsPerComponent: 8,
|
||||
bytesPerRow: width * 4,
|
||||
space: colorSpace,
|
||||
bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue)
|
||||
else {
|
||||
fputs("Failed to create normalized screenshot bitmap at \\(path)\\n", stderr)
|
||||
exit(3)
|
||||
}
|
||||
|
||||
bitmap.size = NSSize(width: width, height: height)
|
||||
let graphicsContext = NSGraphicsContext(cgContext: bitmapContext, flipped: false)
|
||||
NSGraphicsContext.saveGraphicsState()
|
||||
NSGraphicsContext.current = graphicsContext
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(rect: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight)).fill()
|
||||
source.draw(
|
||||
in: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
from: NSRect(x: 0, y: 0, width: width, height: height),
|
||||
operation: .copy,
|
||||
in: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
|
||||
from: NSRect(x: 0, y: 0, width: drawWidth, height: drawHeight),
|
||||
operation: .sourceOver,
|
||||
fraction: 1.0)
|
||||
|
||||
NSColor.black.setFill()
|
||||
NSBezierPath(rect: NSRect(x: width - 146, y: height - 92, width: 124, height: 70)).fill()
|
||||
NSBezierPath(rect: NSRect(x: drawWidth - 146, y: drawHeight - 92, width: 124, height: 70)).fill()
|
||||
|
||||
let paragraphStyle = NSMutableParagraphStyle()
|
||||
paragraphStyle.alignment = .right
|
||||
@@ -260,17 +336,26 @@ def normalize_watch_screenshot_status_bar(path)
|
||||
.paragraphStyle: paragraphStyle,
|
||||
]
|
||||
timeText.draw(
|
||||
in: NSRect(x: width - 134, y: height - 82, width: 102, height: 44),
|
||||
in: NSRect(x: drawWidth - 134, y: drawHeight - 82, width: 102, height: 44),
|
||||
withAttributes: attributes)
|
||||
NSGraphicsContext.restoreGraphicsState()
|
||||
|
||||
guard let png = bitmap.representation(using: .png, properties: [:])
|
||||
guard let output = bitmapContext.makeImage(),
|
||||
let destination = CGImageDestinationCreateWithURL(
|
||||
URL(fileURLWithPath: path) as CFURL,
|
||||
"public.png" as CFString,
|
||||
1,
|
||||
nil)
|
||||
else {
|
||||
fputs("Failed to encode normalized screenshot at \\(path)\\n", stderr)
|
||||
exit(4)
|
||||
}
|
||||
|
||||
try png.write(to: URL(fileURLWithPath: path))
|
||||
CGImageDestinationAddImage(destination, output, nil)
|
||||
guard CGImageDestinationFinalize(destination) else {
|
||||
fputs("Failed to write normalized screenshot at \\(path)\\n", stderr)
|
||||
exit(5)
|
||||
}
|
||||
SWIFT
|
||||
|
||||
Tempfile.create(["openclaw-watch-status-bar", ".swift"]) do |file|
|
||||
@@ -961,6 +1046,7 @@ platform :ios do
|
||||
|
||||
desc "Upload App Store metadata (and optionally screenshots)"
|
||||
lane :metadata do
|
||||
install_ready_for_review_edit_state_lookup!
|
||||
sync_ios_versioning!
|
||||
version_metadata = read_ios_version_metadata
|
||||
api_key = app_store_connect_api_key_config
|
||||
|
||||
@@ -104,7 +104,7 @@ Generate deterministic App Store screenshots:
|
||||
pnpm ios:screenshots
|
||||
```
|
||||
|
||||
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it captures the tab set on `iPhone 16 Pro Max` and `iPad Pro 13-inch (M4)`; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
|
||||
The screenshot lane runs the app with `--openclaw-screenshot-mode`, which enters the built-in connected screenshot fixture instead of pairing with a live gateway. By default it chooses one available large iPhone simulator and one available 13-inch iPad simulator from the installed Xcode runtime; override devices with a comma-separated `OPENCLAW_SNAPSHOT_DEVICES` value when the requested simulators exist locally.
|
||||
|
||||
Upload to App Store Connect:
|
||||
|
||||
|
||||
@@ -2,10 +2,9 @@ project("OpenClaw.xcodeproj")
|
||||
scheme("OpenClawUITests")
|
||||
configuration("Debug")
|
||||
|
||||
devices([
|
||||
"iPhone 16 Pro Max",
|
||||
"iPad Pro 13-inch (M4)",
|
||||
])
|
||||
# The Fastfile screenshot lane resolves concrete device names from the installed
|
||||
# Xcode simulators. Fastlane validates Snapfile devices before lane overrides, so
|
||||
# this file intentionally does not hardcode simulator model names.
|
||||
|
||||
languages([
|
||||
"en-US",
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
// Bundled A2UI runtime resource embedded by OpenClawKit.
|
||||
var __defProp$1 = Object.defineProperty;
|
||||
var __exportAll = (all, no_symbols) => {
|
||||
let target = {};
|
||||
@@ -11936,6 +11935,10 @@ var __runInitializers = function(thisArg, initializers, value) {
|
||||
};
|
||||
return _classThis;
|
||||
})();
|
||||
/**
|
||||
* Canvas A2UI browser bootstrap that installs theme overrides and native bridge
|
||||
* helpers.
|
||||
*/
|
||||
const modalStyles = i$10`
|
||||
dialog {
|
||||
position: fixed;
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
da3373338b7f9c5f5639ad8233a32897d2346a0babe69a77386a7bff154cdcb1 plugin-sdk-api-baseline.json
|
||||
17404d885e0d64ebc8e3c99443921058a8f1aebf76a5e612eb1f0cd7817d48f0 plugin-sdk-api-baseline.jsonl
|
||||
f7247b5bbfe3f96bffffd25a8be2f89b37999e36731f34a159ae21ded1cedd05 plugin-sdk-api-baseline.json
|
||||
ce88a53dadc194ceccc63f50146aee03a1a425f551117da826a21519d5bf80db plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -155,6 +155,7 @@ Notes:
|
||||
|
||||
- `onchar` still responds to explicit @mentions.
|
||||
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
|
||||
- After the bot sends a visible reply in a channel thread, later messages in that same thread are answered without a new @mention or `onchar` prefix, so multi-turn thread conversations keep flowing. Participation is remembered for 7 days of thread inactivity (refreshed on each reply) and persists across gateway restarts. Threads the bot has only observed are unaffected; start a new top-level message to require an explicit mention again.
|
||||
|
||||
## Threading and sessions
|
||||
|
||||
|
||||
@@ -151,6 +151,7 @@ to a group, then mention it or configure the group to run without a mention.
|
||||
groups: {
|
||||
"*": {
|
||||
requireMention: true,
|
||||
commandLevel: "all",
|
||||
historyLimit: 50,
|
||||
tools: { deny: ["exec", "read", "write"] },
|
||||
},
|
||||
@@ -158,6 +159,7 @@ to a group, then mention it or configure the group to run without a mention.
|
||||
name: "Release room",
|
||||
requireMention: false,
|
||||
ignoreOtherMentions: true,
|
||||
commandLevel: "safety",
|
||||
historyLimit: 20,
|
||||
prompt: "Keep replies short and operational.",
|
||||
},
|
||||
@@ -172,6 +174,9 @@ to a group, then mention it or configure the group to run without a mention.
|
||||
settings include:
|
||||
|
||||
- `requireMention`: require an @mention before the bot replies. Default: `true`.
|
||||
- `commandLevel`: control which built-in slash commands can run in groups.
|
||||
Default: `all`, which preserves the pre-existing QQBot group behavior when the
|
||||
setting is omitted.
|
||||
- `ignoreOtherMentions`: drop messages that mention someone else but not the bot.
|
||||
- `historyLimit`: keep recent non-mention group messages as context for the next mentioned turn. Set `0` to disable.
|
||||
- `tools`: allow/deny tools for the whole group.
|
||||
@@ -179,6 +184,17 @@ settings include:
|
||||
- `name`: friendly label used in logs and group context.
|
||||
- `prompt`: per-group behavior prompt appended to the agent context.
|
||||
|
||||
`commandLevel` accepts:
|
||||
|
||||
- `all`: keep recognized built-in commands available as before. Some commands may
|
||||
stay hidden from menus, but authorized users can still run them in the group.
|
||||
- `safety`: allow common collaboration commands such as `/help`, `/btw`, and
|
||||
`/stop`; ask users to run sensitive commands such as `/config`, `/tools`, and
|
||||
`/bash` in private chat.
|
||||
- `strict`: only allow the group-session controls needed for strict group
|
||||
operation. `/stop` still stays urgent so an authorized sender can interrupt an
|
||||
active run.
|
||||
|
||||
Old QQBot `toolPolicy` entries are retired. Run `openclaw doctor --fix` to migrate them to `tools`.
|
||||
|
||||
Activation modes are `mention` and `always`. `requireMention: true` maps to
|
||||
|
||||
12
docs/ci.md
12
docs/ci.md
@@ -42,6 +42,7 @@ or an explicit manual dispatch.
|
||||
| `checks-windows` | Windows-specific process/path tests plus shared runtime import specifier regressions | Windows-relevant changes |
|
||||
| `macos-node` | macOS TypeScript test lane using the shared built artifacts | macOS-relevant changes |
|
||||
| `macos-swift` | Swift lint, build, and tests for the macOS app | macOS-relevant changes |
|
||||
| `ios-build` | Xcode project generation plus the iOS app simulator build | iOS app, shared app kit, or Swabble changes |
|
||||
| `android` | Android unit tests for both flavors plus one debug APK build | Android-relevant changes |
|
||||
| `test-performance-agent` | Daily Codex slow-test optimization after trusted activity | Main CI success or manual dispatch |
|
||||
| `openclaw-performance` | Daily/on-demand Kova runtime performance reports with mock-provider, deep-profile, and GPT 5.5 live lanes | Scheduled and manual dispatch |
|
||||
@@ -52,7 +53,7 @@ or an explicit manual dispatch.
|
||||
2. `preflight` decides which lanes exist at all. The `docs-scope` and `changed-scope` logic are steps inside this job, not standalone jobs.
|
||||
3. `security-fast`, `check-*`, `check-additional-*`, `check-docs`, and `skills-python` fail quickly without waiting on the heavier artifact and platform matrix jobs.
|
||||
4. `build-artifacts` overlaps with the fast Linux lanes so downstream consumers can start as soon as the shared build is ready.
|
||||
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, and `android`.
|
||||
5. Heavier platform and runtime lanes fan out after that: `checks-fast-core`, `checks-fast-contracts-plugins-*`, `checks-fast-contracts-channels-*`, `checks-node-core-*`, `checks-windows`, `macos-node`, `macos-swift`, `ios-build`, and `android`.
|
||||
|
||||
GitHub may mark superseded jobs as `cancelled` when a newer push lands on the same PR or `main` ref. Treat that as CI noise unless the newest run for the same ref is also failing. Matrix jobs use `fail-fast: false`, and `build-artifacts` reports embedded channel, core-support-boundary, and gateway-watch failures directly instead of queuing tiny verifier jobs. The automatic CI concurrency key is versioned (`CI-v7-*`) so a GitHub-side zombie in an old queue group cannot indefinitely block newer main runs. Manual full-suite runs use `CI-manual-v1-*` and do not cancel in-progress runs.
|
||||
|
||||
@@ -80,7 +81,7 @@ When the check fails, update the PR body instead of pushing another code commit.
|
||||
|
||||
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.
|
||||
- **CI workflow edits** validate the Node CI graph plus workflow linting, but do not force Windows, iOS, 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** runs in the `checks-node-core-runtime-tui-pty` Linux Node shard for TUI changes. The shard runs `test/vitest/vitest.tui-pty.config.ts` with `OPENCLAW_TUI_PTY_INCLUDE_LOCAL=1`, so it covers both the deterministic `TuiBackend` fixture lane and the slower `tui --local` smoke that mocks only the external model endpoint.
|
||||
@@ -120,7 +121,7 @@ Treat GitHub titles, comments, bodies, review text, branch names, and commit mes
|
||||
|
||||
## Manual dispatches
|
||||
|
||||
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
|
||||
Manual CI dispatches run the same job graph as normal CI but force every non-Android scoped lane on: Linux Node shards, bundled-plugin shards, plugin and channel contract shards, Node 22 compatibility, `check-*`, `check-additional-*`, built-artifact smoke checks, docs checks, Python skills, Windows, macOS, iOS build, and Control UI i18n. Standalone manual CI dispatches run Android only with `include_android=true`; the full release umbrella enables Android by passing `include_android=true`. Plugin prerelease static checks, the release-only `agentic-plugins` shard, the full extension batch sweep, and plugin prerelease Docker lanes are excluded from CI. The Docker prerelease suite runs only when `Full Release Validation` dispatches the separate `Plugin Prerelease` workflow with the release-validation gate enabled.
|
||||
|
||||
Manual runs use a unique concurrency group so a release-candidate full suite is not cancelled by another push or PR run on the same ref. The optional `target_ref` input lets a trusted caller run that graph against a branch, tag, or full commit SHA while using the workflow file from the selected dispatch ref.
|
||||
|
||||
@@ -140,7 +141,7 @@ gh workflow run full-release-validation.yml --ref main -f ref=<branch-or-sha>
|
||||
| `blacksmith-16vcpu-ubuntu-2404` | `build-artifacts`, `check-lint` (CPU-sensitive enough that 8 vCPU cost more than they saved); install-smoke Docker builds (32-vCPU queue time cost more than it saved) |
|
||||
| `blacksmith-8vcpu-windows-2025` | `checks-windows` |
|
||||
| `blacksmith-6vcpu-macos-15` | `macos-node` on `openclaw/openclaw`; forks fall back to `macos-15` |
|
||||
| `blacksmith-12vcpu-macos-26` | `macos-swift` on `openclaw/openclaw`; forks fall back to `macos-26` |
|
||||
| `blacksmith-12vcpu-macos-26` | `macos-swift` and `ios-build` on `openclaw/openclaw`; forks fall back to `macos-26` |
|
||||
|
||||
Canonical-repo CI keeps Blacksmith as the default runner path for normal push and pull-request runs. `workflow_dispatch` and non-canonical repository runs use GitHub-hosted runners, but normal canonical runs do not currently probe Blacksmith queue health or automatically fall back to GitHub-hosted labels when Blacksmith is unavailable.
|
||||
|
||||
@@ -162,6 +163,7 @@ pnpm test:channels
|
||||
pnpm test:contracts:channels
|
||||
pnpm check:docs # docs format + lint + broken links
|
||||
pnpm build # build dist when CI artifact/smoke checks matter
|
||||
pnpm ios:build # generate and build the iOS app project
|
||||
pnpm ci:timings # summarize the latest origin/main push CI run
|
||||
pnpm ci:timings:recent # compare recent successful main CI runs
|
||||
node scripts/ci-run-timings.mjs <run-id> # summarize wall time, queue time, and slowest jobs
|
||||
@@ -198,7 +200,7 @@ Every lane uploads GitHub artifacts. When `CLAWGRIT_REPORTS_TOKEN` is configured
|
||||
|
||||
## Full Release Validation
|
||||
|
||||
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
|
||||
`Full Release Validation` is the manual umbrella workflow for "run everything before release." It accepts a branch, tag, or full commit SHA, dispatches the manual `CI` workflow with that target, dispatches `Plugin Prerelease` for release-only plugin/package/static/Docker proof, and dispatches `OpenClaw Release Checks` for install smoke, package acceptance, cross-OS package checks, maturity scorecard rendering from QA profile evidence, QA Lab parity, Matrix, and Telegram lanes. Stable and full profiles always include exhaustive live/E2E and Docker release-path soak coverage; the beta profile can opt in with `run_release_soak=true`. The canonical package Telegram E2E runs inside Package Acceptance, so a full candidate does not start a duplicate live poller. After publishing, pass `release_package_spec` to reuse the shipped npm package across release checks, Package Acceptance, Docker, cross-OS, and Telegram without rebuilding. Use `npm_telegram_package_spec` only for a focused published-package Telegram rerun. The Codex plugin live package lane uses the same selected state by default: published `release_package_spec=openclaw@<tag>` derives `codex_plugin_spec=npm:@openclaw/codex@<tag>`, while SHA/artifact runs pack `extensions/codex` from the selected ref. Set `codex_plugin_spec` explicitly for custom plugin sources such as `npm:`, `npm-pack:`, or `git:` specs.
|
||||
|
||||
See [Full release validation](/reference/full-release-validation) for the
|
||||
stage matrix, exact workflow job names, profile differences, artifacts, and
|
||||
|
||||
@@ -23,9 +23,9 @@ OpenClaw agent or Gateway.
|
||||
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills install <slug>
|
||||
openclaw skills update <slug>
|
||||
openclaw skills verify <slug>
|
||||
openclaw skills install @owner/<slug>
|
||||
openclaw skills update @owner/<slug>
|
||||
openclaw skills verify @owner/<slug>
|
||||
|
||||
openclaw plugins search "calendar"
|
||||
openclaw plugins install clawhub:<package>
|
||||
|
||||
@@ -24,13 +24,13 @@ where you have publisher access.
|
||||
Skills are published from a skill folder. The public page is:
|
||||
|
||||
```text
|
||||
https://clawhub.ai/<owner>/<slug>
|
||||
https://clawhub.ai/<owner>/skills/<slug>
|
||||
```
|
||||
|
||||
Example:
|
||||
|
||||
```text
|
||||
https://clawhub.ai/alice/review-helper
|
||||
https://clawhub.ai/alice/skills/review-helper
|
||||
```
|
||||
|
||||
The publish request includes the selected owner, slug, version, changelog, and
|
||||
|
||||
@@ -25,24 +25,24 @@ Related:
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills search --limit 20 --json
|
||||
openclaw skills install <slug>
|
||||
openclaw skills install <slug> --version <version>
|
||||
openclaw skills install @owner/<slug>
|
||||
openclaw skills install @owner/<slug> --version <version>
|
||||
openclaw skills install git:owner/repo
|
||||
openclaw skills install git:owner/repo@main
|
||||
openclaw skills install ./path/to/skill --as custom-name
|
||||
openclaw skills install <slug> --force
|
||||
openclaw skills install <slug> --agent <id>
|
||||
openclaw skills install <slug> --global
|
||||
openclaw skills update <slug>
|
||||
openclaw skills update <slug> --global
|
||||
openclaw skills install @owner/<slug> --force
|
||||
openclaw skills install @owner/<slug> --agent <id>
|
||||
openclaw skills install @owner/<slug> --global
|
||||
openclaw skills update @owner/<slug>
|
||||
openclaw skills update @owner/<slug> --global
|
||||
openclaw skills update --all
|
||||
openclaw skills update --all --agent <id>
|
||||
openclaw skills update --all --global
|
||||
openclaw skills verify <slug>
|
||||
openclaw skills verify <slug> --version <version>
|
||||
openclaw skills verify <slug> --tag <tag>
|
||||
openclaw skills verify <slug> --card
|
||||
openclaw skills verify <slug> --global
|
||||
openclaw skills verify @owner/<slug>
|
||||
openclaw skills verify @owner/<slug> --version <version>
|
||||
openclaw skills verify @owner/<slug> --tag <tag>
|
||||
openclaw skills verify @owner/<slug> --card
|
||||
openclaw skills verify @owner/<slug> --global
|
||||
openclaw skills list
|
||||
openclaw skills list --eligible
|
||||
openclaw skills list --json
|
||||
@@ -64,8 +64,8 @@ openclaw skills workshop reject <proposal-id> --reason "Not reusable"
|
||||
openclaw skills workshop quarantine <proposal-id> --reason "Needs security review"
|
||||
```
|
||||
|
||||
`search`, `update`, and `verify` use ClawHub directly. `install <slug>` installs
|
||||
a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
|
||||
`search`, `update`, and `verify` use ClawHub directly. `install @owner/<slug>`
|
||||
installs a ClawHub skill, `install git:owner/repo[@ref]` clones a Git skill, and
|
||||
`install ./path` copies a local skill directory. By default, `install`, `update`,
|
||||
and `verify` target the active workspace `skills/` directory; with `--global`,
|
||||
they target the shared managed skills directory. `list`/`info`/`check` still
|
||||
@@ -94,19 +94,22 @@ Notes:
|
||||
`SKILL.md`.
|
||||
- `install --as <slug>` overrides the inferred slug for Git and local directory
|
||||
installs.
|
||||
- `install --version <version>` applies only to ClawHub skill slugs.
|
||||
- `install --version <version>` applies only to ClawHub skill refs.
|
||||
- `install --force` overwrites an existing workspace skill folder for the same
|
||||
slug.
|
||||
- `--global` targets the shared managed skills directory and cannot be combined
|
||||
with `--agent <id>`.
|
||||
- `--agent <id>` targets one configured agent workspace and overrides current
|
||||
working directory inference.
|
||||
- `update <slug>` updates a single tracked skill. Add `--global` to target the
|
||||
shared managed skills directory instead of the workspace.
|
||||
- `update @owner/<slug>` updates a single tracked skill. Add `--global` to
|
||||
target the shared managed skills directory instead of the workspace.
|
||||
- `update --all` updates tracked ClawHub installs in the selected workspace, or
|
||||
in the shared managed skills directory when combined with `--global`.
|
||||
- `verify <slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON envelope by
|
||||
default. There is no `--json` flag because JSON is already the default.
|
||||
- `verify @owner/<slug>` prints ClawHub's `clawhub.skill.verify.v1` JSON
|
||||
envelope by default. There is no `--json` flag because JSON is already the
|
||||
default. Bare slugs remain accepted for compatibility when the skill is
|
||||
already installed or unambiguous, but owner-qualified refs avoid publisher
|
||||
ambiguity.
|
||||
- When ClawHub returns server-resolved source provenance, verify JSON also
|
||||
includes a commit-pinned `openclaw.verifiedSourceUrl`. Unavailable or
|
||||
self-declared source URLs stay only in the raw provenance envelope and are not
|
||||
|
||||
@@ -68,6 +68,14 @@
|
||||
"source": "/reference/openclaw-sdk-api-design",
|
||||
"destination": "/gateway/external-apps"
|
||||
},
|
||||
{
|
||||
"source": "/reference/maturity-scorecard",
|
||||
"destination": "/maturity/scorecard"
|
||||
},
|
||||
{
|
||||
"source": "/reference/maturity-taxonomy",
|
||||
"destination": "/maturity/taxonomy"
|
||||
},
|
||||
{
|
||||
"source": "/mcp",
|
||||
"destination": "/cli/mcp"
|
||||
@@ -1852,6 +1860,8 @@
|
||||
{
|
||||
"group": "Release and CI",
|
||||
"pages": [
|
||||
"maturity/scorecard",
|
||||
"maturity/taxonomy",
|
||||
"reference/RELEASING",
|
||||
"reference/full-release-validation",
|
||||
"reference/release-performance-sweep",
|
||||
|
||||
@@ -346,10 +346,10 @@ lives on the [First-run FAQ](/help/faq-first-run).
|
||||
```bash
|
||||
openclaw skills search "calendar"
|
||||
openclaw skills search --limit 20
|
||||
openclaw skills install <skill-slug>
|
||||
openclaw skills install <skill-slug> --version <version>
|
||||
openclaw skills install <skill-slug> --force
|
||||
openclaw skills install <skill-slug> --global
|
||||
openclaw skills install @owner/<skill-slug>
|
||||
openclaw skills install @owner/<skill-slug> --version <version>
|
||||
openclaw skills install @owner/<skill-slug> --force
|
||||
openclaw skills install @owner/<skill-slug> --global
|
||||
openclaw skills update --all
|
||||
openclaw skills update --all --global
|
||||
openclaw skills list --eligible
|
||||
@@ -433,11 +433,11 @@ lives on the [First-run FAQ](/help/faq-first-run).
|
||||
Install skills:
|
||||
|
||||
```bash
|
||||
openclaw skills install <skill-slug>
|
||||
openclaw skills install @owner/<skill-slug>
|
||||
openclaw skills update --all
|
||||
```
|
||||
|
||||
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install <slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
|
||||
Native installs land in the active workspace `skills/` directory. For shared skills across all local agents, use `openclaw skills install @owner/<skill-slug> --global` (or place them manually in `~/.openclaw/skills/<name>/SKILL.md`). If only some agents should see a shared install, configure `agents.defaults.skills` or `agents.list[].skills`. Some skills expect binaries installed via Homebrew; on Linux that means Linuxbrew (see the Homebrew Linux FAQ entry above). See [Skills](/tools/skills), [Skills config](/tools/skills-config), and [ClawHub](/tools/clawhub).
|
||||
|
||||
</Accordion>
|
||||
|
||||
|
||||
@@ -110,14 +110,18 @@ systemctl --user daemon-reload
|
||||
### Windows (Scheduled Task)
|
||||
|
||||
Default task name is `OpenClaw Gateway` (or `OpenClaw Gateway (<profile>)`).
|
||||
The task script lives under your state dir.
|
||||
The task script lives under your state dir as `gateway.cmd`; current installs may
|
||||
also create a windowless `gateway.vbs` launcher that Task Scheduler runs instead
|
||||
of opening `gateway.cmd` directly.
|
||||
|
||||
```powershell
|
||||
schtasks /Delete /F /TN "OpenClaw Gateway"
|
||||
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd"
|
||||
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.cmd" -ErrorAction SilentlyContinue
|
||||
Remove-Item -Force "$env:USERPROFILE\.openclaw\gateway.vbs" -ErrorAction SilentlyContinue
|
||||
```
|
||||
|
||||
If you used a profile, delete the matching task name and `~\.openclaw-<profile>\gateway.cmd`.
|
||||
If you used a profile, delete the matching task name and the `gateway.cmd` /
|
||||
`gateway.vbs` files under `~\.openclaw-<profile>`.
|
||||
|
||||
## Normal install vs source checkout
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -124,8 +124,11 @@ openclaw gateway status --json
|
||||
```
|
||||
|
||||
Native Windows CLI and Gateway flows are supported and continue to improve.
|
||||
Managed startup uses Windows Scheduled Tasks when available and falls back to a
|
||||
per-user Startup-folder login item if task creation is denied.
|
||||
Managed startup uses Windows Scheduled Tasks when available. The task keeps the
|
||||
readable `gateway.cmd` script in the OpenClaw state dir, but launches it through
|
||||
a generated `gateway.vbs` WScript wrapper so the background Gateway does not open
|
||||
a visible console window. If task creation is denied, OpenClaw falls back to a
|
||||
per-user Startup-folder login item.
|
||||
|
||||
To install the Gateway service:
|
||||
|
||||
|
||||
@@ -259,14 +259,10 @@ under `describe("runSideQuestion")`.
|
||||
fallback for whatever runtimes do not have a peer surface.
|
||||
- PI session state is not migrated when an agent switches to `copilot`.
|
||||
Selection is per attempt; existing PI sessions remain valid.
|
||||
- **Interactive `ask_user` is not yet wired.** The SDK's
|
||||
`onUserInputRequest` handler is intentionally not registered, which
|
||||
per the SDK contract hides the `ask_user` tool from the model
|
||||
entirely. Agents running under this harness make best-judgment
|
||||
decisions from the initial prompt rather than asking clarifying
|
||||
questions mid-turn. A follow-up will port the codex pattern at
|
||||
`extensions/codex/src/app-server/user-input-bridge.ts` to route SDK
|
||||
`UserInputRequest`s through the OpenClaw channel/TUI prompt path.
|
||||
- `ask_user` uses the same OpenClaw prompt-and-reply path as the Codex
|
||||
harness. When the Copilot SDK asks for user input, OpenClaw posts a
|
||||
blocking prompt to the active channel/TUI and the next queued user
|
||||
message resolves the SDK request.
|
||||
|
||||
## Permissions and ask_user
|
||||
|
||||
@@ -328,11 +324,15 @@ the tool bridge. The bridge also forwards the bounded tool-construction
|
||||
controls it can enforce at the SDK boundary: `includeCoreTools`, the
|
||||
runtime tool allowlist, and `toolConstructionPlan`.
|
||||
|
||||
The remaining PI tool-search/code-mode fields are intentionally **not**
|
||||
forwarded at MVP and tracked as follow-ups: `toolSearchCatalogRef`,
|
||||
`includeToolSearchControls`, and `toolSearchCatalogExecutor`. Those
|
||||
controls drive PI's native tool-search UI and have no direct Copilot SDK
|
||||
analog yet.
|
||||
The bridge also uses the shared harness tool-surface helper from
|
||||
`openclaw/plugin-sdk/agent-harness-tool-runtime` for PI parity. When
|
||||
tool-search is enabled, the SDK sees compact control tools plus a hidden
|
||||
catalog executor instead of every OpenClaw tool schema. When code mode is
|
||||
enabled, the helper builds the same code-mode control surface and catalog
|
||||
lifecycle used by other agent harnesses. Local-model lean defaults,
|
||||
runtime-compatible schema filtering, directory hydration, and catalog
|
||||
cleanup all stay in the shared helper so Copilot and Codex-adjacent
|
||||
harnesses do not drift.
|
||||
|
||||
### Session-level GitHub token
|
||||
|
||||
@@ -349,7 +349,10 @@ When the resolved mode is `useLoggedInUser`, the session-level field
|
||||
is omitted so the SDK keeps deriving identity from the logged-in
|
||||
identity.
|
||||
|
||||
`ask_user` is intentionally hidden — see Limitations above.
|
||||
`ask_user` uses `SessionConfig.onUserInputRequest`. The bridge accepts
|
||||
choice indexes or labels for fixed-choice requests, accepts free-form
|
||||
answers when the SDK request allows them, and cancels a pending request
|
||||
when the OpenClaw attempt is aborted.
|
||||
|
||||
## Related
|
||||
|
||||
|
||||
@@ -196,6 +196,23 @@ finish. Both helpers accept the same `{ event, ctx }` payload as
|
||||
`runAgentHarnessAgentEndHook(...)`; their failures do not alter the completed
|
||||
attempt result.
|
||||
|
||||
### User input and tool surfaces
|
||||
|
||||
Native harnesses that expose a runtime-level user-input request should use the
|
||||
user-input helpers from `openclaw/plugin-sdk/agent-harness-runtime` to format
|
||||
the prompt, deliver it through OpenClaw's blocking reply path, and normalize
|
||||
choice/free-form answers back into the runtime's native response shape. The
|
||||
helper keeps channel/TUI presentation consistent while each harness keeps its
|
||||
own protocol parsing and pending-request lifecycle.
|
||||
|
||||
Native harnesses that need PI-like compact tool routing should use
|
||||
`createAgentHarnessToolSurfaceRuntime(...)` from
|
||||
`openclaw/plugin-sdk/agent-harness-tool-runtime`. It owns
|
||||
tool-search/code-mode control selection, local-model lean defaults,
|
||||
runtime-compatible schema filtering, hidden catalog execution, directory
|
||||
hydration, and catalog cleanup. Harnesses still own their SDK-specific tool
|
||||
conversion and native execution callback.
|
||||
|
||||
### Native Codex harness mode
|
||||
|
||||
The bundled `codex` harness is the native Codex mode for embedded OpenClaw
|
||||
|
||||
@@ -84,8 +84,8 @@ Choose the Token Plan auth choice that matches the regional base URL shown in Xi
|
||||
|
||||
| Model ref | Input | Context | Max output | Reasoning | Notes |
|
||||
| --------------------------------- | ----------- | --------- | ---------- | --------- | ------------- |
|
||||
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 32,000 | Yes | Default model |
|
||||
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 32,000 | Yes | Multimodal |
|
||||
| `xiaomi-token-plan/mimo-v2.5-pro` | text | 1,048,576 | 131,072 | Yes | Default model |
|
||||
| `xiaomi-token-plan/mimo-v2.5` | text, image | 1,048,576 | 131,072 | Yes | Multimodal |
|
||||
|
||||
<Tip>
|
||||
Token Plan onboarding validates the key shape and warns when a `tp-...` key is entered into the pay-as-you-go path, or an `sk-...` key is entered into the Token Plan path.
|
||||
@@ -222,7 +222,7 @@ Token Plan:
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 32000,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
{
|
||||
id: "mimo-v2.5",
|
||||
@@ -230,7 +230,7 @@ Token Plan:
|
||||
reasoning: true,
|
||||
input: ["text", "image"],
|
||||
contextWindow: 1048576,
|
||||
maxTokens: 32000,
|
||||
maxTokens: 131072,
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -67,7 +67,7 @@ Wraps papla.media TTS and sends results as Telegram voice notes (no annoying aut
|
||||
<img src="/assets/showcase/papla-tts.jpg" alt="Telegram voice note output from TTS" />
|
||||
</Card>
|
||||
|
||||
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/codexmonitor">
|
||||
<Card title="CodexMonitor" icon="eye" href="https://clawhub.ai/odrobnik/skills/codexmonitor">
|
||||
**@odrobnik** • `devtools` `codex` `brew`
|
||||
|
||||
Homebrew-installed helper to list, inspect, and watch local OpenAI Codex sessions (CLI + VS Code).
|
||||
@@ -75,7 +75,7 @@ Homebrew-installed helper to list, inspect, and watch local OpenAI Codex session
|
||||
<img src="/assets/showcase/codexmonitor.png" alt="CodexMonitor on ClawHub" />
|
||||
</Card>
|
||||
|
||||
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/bambu-cli">
|
||||
<Card title="Bambu 3D Printer Control" icon="print" href="https://clawhub.ai/tobiasbischoff/skills/bambu-cli">
|
||||
**@tobiasbischoff** • `hardware` `3d-printing` `skill`
|
||||
|
||||
Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibration, and more.
|
||||
@@ -83,7 +83,7 @@ Control and troubleshoot BambuLab printers: status, jobs, camera, AMS, calibrati
|
||||
<img src="/assets/showcase/bambu-cli.png" alt="Bambu CLI skill on ClawHub" />
|
||||
</Card>
|
||||
|
||||
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/wienerlinien">
|
||||
<Card title="Vienna transport (Wiener Linien)" icon="train" href="https://clawhub.ai/hjanuschka/skills/wienerlinien">
|
||||
**@hjanuschka** • `travel` `transport` `skill`
|
||||
|
||||
Real-time departures, disruptions, elevator status, and routing for Vienna's public transport.
|
||||
@@ -97,7 +97,7 @@ Real-time departures, disruptions, elevator status, and routing for Vienna's pub
|
||||
Automated UK school meal booking via ParentPay. Uses mouse coordinates for reliable table cell clicking.
|
||||
</Card>
|
||||
|
||||
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/skills/r2-upload">
|
||||
<Card title="R2 upload (Send Me My Files)" icon="cloud-arrow-up" href="https://clawhub.ai/julianengel/skills/r2-upload">
|
||||
**@julianengel** • `files` `r2` `presigned-urls`
|
||||
|
||||
Upload to Cloudflare R2/S3 and generate secure presigned download links. Useful for remote OpenClaw instances.
|
||||
@@ -267,7 +267,7 @@ Speech-first entry points, phone bridges, and transcription-heavy workflows.
|
||||
Vapi voice assistant to OpenClaw HTTP bridge. Near real-time phone calls with your agent.
|
||||
</Card>
|
||||
|
||||
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/openrouter-transcribe">
|
||||
<Card title="OpenRouter transcription" icon="microphone" href="https://clawhub.ai/obviyus/skills/openrouter-transcribe">
|
||||
**@obviyus** • `transcription` `multilingual` `skill`
|
||||
|
||||
Multi-lingual audio transcription via OpenRouter (Gemini, and more). Available on ClawHub.
|
||||
@@ -289,8 +289,8 @@ Packaging, deployment, and integrations that make OpenClaw easier to run and ext
|
||||
OpenClaw gateway running on Home Assistant OS with SSH tunnel support and persistent state.
|
||||
</Card>
|
||||
|
||||
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/skills/homeassistant">
|
||||
**ClawHub** • `homeassistant` `skill` `automation`
|
||||
<Card title="Home Assistant skill" icon="toggle-on" href="https://clawhub.ai/homeofe/skills/openclaw-homeassistant">
|
||||
**@homeofe** • `homeassistant` `skill` `automation`
|
||||
|
||||
Control and automate Home Assistant devices via natural language.
|
||||
|
||||
@@ -303,8 +303,8 @@ Control and automate Home Assistant devices via natural language.
|
||||
Batteries-included nixified OpenClaw configuration for reproducible deployments.
|
||||
</Card>
|
||||
|
||||
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/skills/caldav-calendar">
|
||||
**ClawHub** • `calendar` `caldav` `skill`
|
||||
<Card title="CalDAV calendar" icon="calendar" href="https://clawhub.ai/asleep123/skills/caldav-calendar">
|
||||
**@asleep123** • `calendar` `caldav` `skill`
|
||||
|
||||
Calendar skill using khal and vdirsyncer. Self-hosted calendar integration.
|
||||
|
||||
|
||||
750
docs/style.css
750
docs/style.css
@@ -135,3 +135,753 @@ html.dark .nav-tabs-underline {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
.maturity-hero {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
margin: 10px 0 38px;
|
||||
padding: 4px 0 26px 20px;
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 22%, transparent);
|
||||
border-left: 3px solid rgb(var(--primary));
|
||||
}
|
||||
|
||||
.maturity-hero-compact {
|
||||
margin-bottom: 34px;
|
||||
}
|
||||
|
||||
.maturity-hero h2 {
|
||||
max-width: 46rem;
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.maturity-hero-title {
|
||||
max-width: 46rem;
|
||||
margin: 0;
|
||||
font-size: clamp(26px, 3vw, 38px);
|
||||
font-weight: 750;
|
||||
line-height: 1.08;
|
||||
letter-spacing: -0.01em;
|
||||
}
|
||||
|
||||
.maturity-hero > p:not(.maturity-kicker):not(.maturity-jump-links) {
|
||||
max-width: 58rem;
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 1.65;
|
||||
opacity: 0.76;
|
||||
}
|
||||
|
||||
.maturity-kicker {
|
||||
margin: 0;
|
||||
color: rgb(var(--primary));
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.1em;
|
||||
line-height: 1.3;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-jump-links {
|
||||
margin: 0;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.maturity-jump-links a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-jump-links a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.maturity-score-stable,
|
||||
.maturity-band-stable {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-score-beta,
|
||||
.maturity-band-beta {
|
||||
color: #849fd2;
|
||||
}
|
||||
|
||||
.maturity-score-alpha,
|
||||
.maturity-band-alpha {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-score-experimental,
|
||||
.maturity-band-experimental {
|
||||
color: #dc7669;
|
||||
}
|
||||
|
||||
.maturity-score-clawesome,
|
||||
.maturity-band-clawesome {
|
||||
color: #46b59a;
|
||||
}
|
||||
|
||||
.maturity-level-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
width: max-content;
|
||||
max-width: 100%;
|
||||
padding: 3px 8px;
|
||||
border: 1px solid color-mix(in oklab, currentColor 32%, transparent);
|
||||
border-radius: 999px;
|
||||
background: color-mix(in oklab, currentColor 10%, transparent);
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
line-height: 1.25;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-level-code {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.72;
|
||||
}
|
||||
|
||||
.maturity-level-experimental {
|
||||
color: #dc7669;
|
||||
}
|
||||
|
||||
.maturity-level-alpha {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-level-beta {
|
||||
color: #849fd2;
|
||||
}
|
||||
|
||||
.maturity-level-stable {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-level-clawesome {
|
||||
color: #46b59a;
|
||||
}
|
||||
|
||||
.maturity-summary-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 14px 0 20px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 18%, transparent);
|
||||
}
|
||||
|
||||
.maturity-summary-item {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
min-width: 0;
|
||||
padding: 18px 20px 18px 0;
|
||||
}
|
||||
|
||||
.maturity-summary-item + .maturity-summary-item {
|
||||
padding-left: 20px;
|
||||
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-summary-heading {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.maturity-summary-value {
|
||||
display: inline-block;
|
||||
font-size: 30px;
|
||||
font-weight: 750;
|
||||
letter-spacing: -0.04em;
|
||||
}
|
||||
|
||||
.maturity-summary-heading > span:not(.maturity-summary-value) {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-summary-bar {
|
||||
height: 7px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklab, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-summary-bar span {
|
||||
display: block;
|
||||
width: calc(var(--score) * 1%);
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.maturity-summary-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 4px 10px;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.maturity-summary-meta span:first-child {
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-summary-meta span:last-child {
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-band-list {
|
||||
display: flex;
|
||||
margin: 12px 0 30px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
}
|
||||
|
||||
.maturity-band {
|
||||
display: grid;
|
||||
flex: 1 1 0;
|
||||
gap: 3px;
|
||||
padding: 10px 12px 11px 0;
|
||||
}
|
||||
|
||||
.maturity-band + .maturity-band {
|
||||
padding-left: 12px;
|
||||
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
}
|
||||
|
||||
.maturity-band-title {
|
||||
font-size: 12px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-band-title + span {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maturity-band > span:last-child {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maturity-band span {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.maturity-band .maturity-level-pill {
|
||||
font-size: 10px;
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-band .maturity-level-pill span {
|
||||
font-size: inherit;
|
||||
opacity: inherit;
|
||||
}
|
||||
|
||||
.maturity-score {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-score-label {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.maturity-score-label > span:first-child {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-score-label > span:last-child {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.maturity-score-label .maturity-level-pill {
|
||||
gap: 4px;
|
||||
padding: 2px 6px;
|
||||
font-size: 9px;
|
||||
}
|
||||
|
||||
.maturity-score-label-text {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-summary-meta .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-meter {
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
height: 3px;
|
||||
overflow: hidden;
|
||||
background: color-mix(in oklab, currentColor 15%, transparent);
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.maturity-meter > span {
|
||||
display: block;
|
||||
width: 0;
|
||||
height: 100%;
|
||||
background: currentColor;
|
||||
}
|
||||
|
||||
.maturity-score-unscored,
|
||||
.maturity-lts-none {
|
||||
color: inherit;
|
||||
opacity: 0.52;
|
||||
}
|
||||
|
||||
.maturity-surface-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
margin: 8px 0 22px;
|
||||
}
|
||||
|
||||
.maturity-surface-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(190px, 1.55fr) repeat(3, minmax(110px, 1fr)) minmax(72px, 0.55fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid color-mix(in oklab, currentColor 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-surface-row-header {
|
||||
padding: 0 0 9px;
|
||||
border-top: 0;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.56;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-surface-name {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
border-bottom: 0 !important;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-surface-name:hover .maturity-surface-title {
|
||||
color: rgb(var(--primary));
|
||||
}
|
||||
|
||||
.maturity-surface-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.maturity-surface-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-surface-meta > span:not(.maturity-level-pill) {
|
||||
font-size: 11px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-surface-meta .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-surface-metric {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-surface-metric-label {
|
||||
display: none;
|
||||
font-size: 10px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-surface-support {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.maturity-lts {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 5px;
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.maturity-lts::before {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.maturity-lts-partial {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-lts-full {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-evidence-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
margin: 14px 0 24px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
}
|
||||
|
||||
.maturity-evidence-card {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
min-width: 0;
|
||||
padding: 14px 16px 14px 0;
|
||||
}
|
||||
|
||||
.maturity-evidence-card + .maturity-evidence-card {
|
||||
padding-left: 16px;
|
||||
border-left: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
}
|
||||
|
||||
.maturity-evidence-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-evidence-card span {
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.maturity-readiness-summary {
|
||||
margin: 0 0 12px;
|
||||
font-size: 12px;
|
||||
opacity: 0.65;
|
||||
}
|
||||
|
||||
.maturity-readiness-list {
|
||||
display: grid;
|
||||
margin: 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
|
||||
}
|
||||
|
||||
.maturity-readiness-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1.7fr) minmax(120px, 0.8fr) minmax(110px, 0.7fr);
|
||||
gap: 14px;
|
||||
align-items: center;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 10%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.maturity-readiness-row-header {
|
||||
padding: 8px 0;
|
||||
border-bottom-color: color-mix(in oklab, rgb(var(--primary)) 14%, transparent);
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.56;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-readiness-area {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-readiness-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-readiness-status {
|
||||
font-size: 10px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-readiness-status-ready {
|
||||
color: #4ca574;
|
||||
}
|
||||
|
||||
.maturity-readiness-status-partially-reviewed {
|
||||
color: #d39a4b;
|
||||
}
|
||||
|
||||
.maturity-readiness-status-needs-review {
|
||||
color: #dc7669;
|
||||
}
|
||||
|
||||
.maturity-category-list {
|
||||
display: grid;
|
||||
width: 100%;
|
||||
margin-top: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.maturity-category-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(180px, 1.55fr) repeat(3, minmax(100px, 1fr)) minmax(140px, 1.2fr);
|
||||
gap: 12px;
|
||||
align-items: center;
|
||||
padding: 12px 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
|
||||
font-size: 11px;
|
||||
}
|
||||
|
||||
.maturity-category-row-header {
|
||||
padding: 8px 0;
|
||||
border-top: 0;
|
||||
color: inherit;
|
||||
font-size: 10px;
|
||||
font-weight: 750;
|
||||
letter-spacing: 0.04em;
|
||||
opacity: 0.56;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.maturity-category-area {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.maturity-category-title {
|
||||
overflow-wrap: anywhere;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-category-area > span:last-child {
|
||||
font-size: 10px;
|
||||
opacity: 0.62;
|
||||
}
|
||||
|
||||
.maturity-category-docs {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.maturity-category-docs a {
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-category-docs a:hover {
|
||||
text-decoration: underline;
|
||||
text-underline-offset: 3px;
|
||||
}
|
||||
|
||||
.maturity-level-list {
|
||||
display: grid;
|
||||
margin: 12px 0 28px;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 16%, transparent);
|
||||
}
|
||||
|
||||
.maturity-level-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(130px, 0.32fr) minmax(0, 1fr);
|
||||
gap: 4px 14px;
|
||||
padding: 13px 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 11%, transparent);
|
||||
}
|
||||
|
||||
.maturity-level-row:first-child {
|
||||
border-top: 0;
|
||||
}
|
||||
|
||||
.maturity-level-title {
|
||||
grid-row: span 2;
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-level-title .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-level-row span,
|
||||
.maturity-level-promotion {
|
||||
font-size: 12px;
|
||||
line-height: 1.45;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.maturity-surface-link {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
margin: 0;
|
||||
padding: 11px 0;
|
||||
border-bottom: 1px solid color-mix(in oklab, currentColor 14%, transparent);
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.maturity-surface-link:hover {
|
||||
color: rgb(var(--primary));
|
||||
}
|
||||
|
||||
.maturity-surface-link .maturity-surface-title {
|
||||
font-size: 13px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.maturity-surface-link > .maturity-surface-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.maturity-surface-link > .maturity-surface-meta > span:not(.maturity-level-pill) {
|
||||
font-size: 11px;
|
||||
opacity: 0.68;
|
||||
}
|
||||
|
||||
.maturity-surface-link > .maturity-surface-meta .maturity-level-pill {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.maturity-surface-rollup {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 5px 14px;
|
||||
margin: 0 0 14px;
|
||||
padding: 9px 0;
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
|
||||
border-bottom: 1px solid color-mix(in oklab, rgb(var(--primary)) 13%, transparent);
|
||||
}
|
||||
|
||||
.maturity-surface-rollup > span {
|
||||
color: inherit;
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
#content table .maturity-score,
|
||||
#content table .maturity-lts {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
@media (max-width: 960px) {
|
||||
.maturity-summary-grid,
|
||||
.maturity-evidence-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.maturity-summary-item,
|
||||
.maturity-summary-item + .maturity-summary-item,
|
||||
.maturity-evidence-card,
|
||||
.maturity-evidence-card + .maturity-evidence-card {
|
||||
padding: 14px 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.maturity-summary-item + .maturity-summary-item,
|
||||
.maturity-evidence-card + .maturity-evidence-card {
|
||||
border-top: 1px solid color-mix(in oklab, rgb(var(--primary)) 12%, transparent);
|
||||
}
|
||||
|
||||
.maturity-surface-row {
|
||||
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(98px, 1fr)) minmax(70px, 0.5fr);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.maturity-category-row {
|
||||
grid-template-columns: minmax(160px, 1.35fr) repeat(3, minmax(86px, 1fr)) minmax(110px, 1fr);
|
||||
gap: 8px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.maturity-hero {
|
||||
padding-left: 14px;
|
||||
}
|
||||
|
||||
.maturity-surface-row-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.maturity-surface-row {
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 9px 12px;
|
||||
}
|
||||
|
||||
.maturity-surface-metric {
|
||||
display: grid;
|
||||
grid-template-columns: auto 1fr;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.maturity-surface-metric-label {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.maturity-surface-support {
|
||||
justify-self: end;
|
||||
}
|
||||
|
||||
.maturity-readiness-row,
|
||||
.maturity-category-row {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 5px;
|
||||
}
|
||||
|
||||
.maturity-readiness-row-header,
|
||||
.maturity-category-row-header {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.maturity-category-docs {
|
||||
padding-top: 3px;
|
||||
}
|
||||
|
||||
.maturity-band-list {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
|
||||
.maturity-band + .maturity-band {
|
||||
padding-left: 0;
|
||||
border-left: 0;
|
||||
}
|
||||
|
||||
.maturity-level-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.maturity-level-title {
|
||||
grid-row: auto;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -225,7 +225,7 @@ See [Skill Workshop](/tools/skill-workshop) for the full proposal lifecycle.
|
||||
metadata:
|
||||
|
||||
```bash
|
||||
openclaw skills install clawhub-publish
|
||||
openclaw skills install @openclaw/clawhub-publish
|
||||
```
|
||||
|
||||
</Step>
|
||||
|
||||
@@ -88,8 +88,9 @@ still returns one synthesized answer with citations rather than an N-result
|
||||
list.
|
||||
|
||||
`freshness` accepts `day`, `week`, `month`, `year`, and the shared shortcuts
|
||||
`pd`, `pw`, `pm`, and `py`. OpenClaw converts these values, or an explicit
|
||||
`date_after`/`date_before` range, into Gemini Google Search grounding's
|
||||
`pd`, `pw`, `pm`, and `py`. `day`/`pd` adds a recency instruction to the Gemini
|
||||
query instead of a hard 24-hour range. `week`, `month`, `year`, and explicit
|
||||
`date_after`/`date_before` ranges set Gemini Google Search grounding's
|
||||
`timeRangeFilter`. `country`, `language`, and `domain_filter` are not supported.
|
||||
|
||||
## Model selection
|
||||
|
||||
@@ -145,15 +145,15 @@ publish and sync.
|
||||
|
||||
| Action | Command |
|
||||
| ---------------------------------- | ------------------------------------------------------ |
|
||||
| Install a skill into the workspace | `openclaw skills install <slug>` |
|
||||
| Install a skill into the workspace | `openclaw skills install @owner/<slug>` |
|
||||
| Install from a Git repository | `openclaw skills install git:owner/repo@ref` |
|
||||
| Install a local skill directory | `openclaw skills install ./path/to/skill --as my-tool` |
|
||||
| Install for all local agents | `openclaw skills install <slug> --global` |
|
||||
| Install for all local agents | `openclaw skills install @owner/<slug> --global` |
|
||||
| Update all workspace skills | `openclaw skills update --all` |
|
||||
| Update a shared managed skill | `openclaw skills update <slug> --global` |
|
||||
| Update a shared managed skill | `openclaw skills update @owner/<slug> --global` |
|
||||
| Update all shared managed skills | `openclaw skills update --all --global` |
|
||||
| Verify a skill's trust envelope | `openclaw skills verify <slug>` |
|
||||
| Print the generated Skill Card | `openclaw skills verify <slug> --card` |
|
||||
| Verify a skill's trust envelope | `openclaw skills verify @owner/<slug>` |
|
||||
| Print the generated Skill Card | `openclaw skills verify @owner/<slug> --card` |
|
||||
| Publish / sync via ClawHub CLI | `clawhub sync --all` |
|
||||
|
||||
<AccordionGroup>
|
||||
@@ -171,15 +171,17 @@ publish and sync.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Verification and security scanning">
|
||||
`openclaw skills verify <slug>` asks ClawHub for the skill's
|
||||
`openclaw skills verify @owner/<slug>` asks ClawHub for the skill's
|
||||
`clawhub.skill.verify.v1` trust envelope. Installed ClawHub skills verify
|
||||
against the version and registry recorded in `.clawhub/origin.json`.
|
||||
Bare slugs remain accepted for existing installed or unambiguous skills, but
|
||||
owner-qualified refs avoid publisher ambiguity.
|
||||
|
||||
ClawHub skill pages expose the latest security scan state before install,
|
||||
with detail pages for VirusTotal, ClawScan, and static analysis. The
|
||||
command exits non-zero when ClawHub marks verification as failed. Publishers
|
||||
recover false positives through the ClawHub dashboard or
|
||||
`clawhub skill rescan <slug>`.
|
||||
`clawhub skill rescan @owner/<slug>`.
|
||||
|
||||
</Accordion>
|
||||
<Accordion title="Private archive installs">
|
||||
|
||||
@@ -389,8 +389,8 @@ show the `x_search` prompt.
|
||||
freshness ranges require both start and end dates.
|
||||
Gemini, Grok, and Kimi return one synthesized answer with citations. They
|
||||
accept `count` for shared-tool compatibility, but it does not change the
|
||||
grounded answer shape. Gemini supports `freshness`, `date_after`, and
|
||||
`date_before` by converting them to Google Search grounding time ranges.
|
||||
grounded answer shape. Gemini treats `day` freshness as a recency hint; wider
|
||||
freshness values and explicit dates set Google Search grounding time ranges.
|
||||
Perplexity behaves the same way when you use the Sonar/OpenRouter
|
||||
compatibility path (`plugins.entries.perplexity.config.webSearch.baseUrl` /
|
||||
`model` or `OPENROUTER_API_KEY`).
|
||||
|
||||
60
extensions/acpx/npm-shrinkwrap.json
generated
60
extensions/acpx/npm-shrinkwrap.json
generated
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
"acpx": "0.10.0",
|
||||
"acpx": "0.11.2",
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
@@ -196,9 +196,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/core": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.4.1.tgz",
|
||||
"integrity": "sha512-FILJa1gGKEFTGZAJE9RpVhrjKz3c3h4ar60dSv6cGuDqufQ84YEIS3GAGvZiN+H6yaLbbvTFNejjCC4tXpZEuw==",
|
||||
"version": "1.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/core/-/core-1.3.1.tgz",
|
||||
"integrity": "sha512-fT1qHVGAag4IEkrupZ6lRRbNCs1vS9P01KB/sG8zKgvUztbYtFBtQpjSITNwooDZ83tpsPzP0mRNs1/KVszCRA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"fast-wrap-ansi": "^0.2.0",
|
||||
@@ -209,12 +209,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@clack/prompts": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.5.1.tgz",
|
||||
"integrity": "sha512-zccHj2z2oCCO4yrDiRSlFOxWerGqRiysP7a5jPK6uoI9URKAquwY42Dd/iUP8JWHxEzdRe4TlbvZCo8z1/mhrw==",
|
||||
"version": "1.4.0",
|
||||
"resolved": "https://registry.npmjs.org/@clack/prompts/-/prompts-1.4.0.tgz",
|
||||
"integrity": "sha512-S0My7XPGIgpRWMDG8uRqalbgT+a6FmCUdOW+HaIOVVpUPHOb7RrpvjTjiODadKp06fsrVDJZlIzc6yCTp4AnxA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/core": "1.4.1",
|
||||
"@clack/core": "1.3.1",
|
||||
"fast-string-width": "^3.0.2",
|
||||
"fast-wrap-ansi": "^0.2.0",
|
||||
"sisteransi": "^1.0.5"
|
||||
@@ -701,6 +701,7 @@
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@zed-industries/codex-acp/-/codex-acp-0.15.0.tgz",
|
||||
"integrity": "sha512-eAv7sGBeiYrYkOulF729nrM51szS7WIhBtugRj5wWq6csRKZUhAZfoUZlF8xUWdHPtOIzd/eT6MNG6gMHu6z0w==",
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"codex-acp": "bin/codex-acp.js"
|
||||
@@ -721,6 +722,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -737,6 +739,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -753,6 +756,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -769,6 +773,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -785,6 +790,7 @@
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -801,6 +807,7 @@
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"deprecated": "This package has been replaced by @agentclientprotocol/codex-acp. Please migrate to continue receiving updates.",
|
||||
"license": "Apache-2.0",
|
||||
"optional": true,
|
||||
"os": [
|
||||
@@ -824,15 +831,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/acpx": {
|
||||
"version": "0.10.0",
|
||||
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.10.0.tgz",
|
||||
"integrity": "sha512-hd48XV03gG3sd409T1lDrOKJTTz1ap4g0wrndXjxQ590tN85pBYlvfNLyerybvGRrtUGsZjNdt99r1jpIt6ukA==",
|
||||
"version": "0.11.2",
|
||||
"resolved": "https://registry.npmjs.org/acpx/-/acpx-0.11.2.tgz",
|
||||
"integrity": "sha512-ksTmfJDVqUAJJXsNDamEno03AMZ/aAZzXk/h5nt61VsLc/jcpoDMfCVpErzuYNJjwCd0V6Zm5o6F8OoqxsjQWA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/sdk": "^0.22.1",
|
||||
"commander": "^14.0.3",
|
||||
"skillflag": "^0.1.4",
|
||||
"tsx": "^4.22.0",
|
||||
"@agentclientprotocol/sdk": "^0.28.1",
|
||||
"commander": "^15.0.0",
|
||||
"skillflag": "^0.2.0",
|
||||
"tsx": "^4.22.4",
|
||||
"zod": "^4.4.3"
|
||||
},
|
||||
"bin": {
|
||||
@@ -842,6 +849,15 @@
|
||||
"node": ">=22.13.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acpx/node_modules/@agentclientprotocol/sdk": {
|
||||
"version": "0.28.1",
|
||||
"resolved": "https://registry.npmjs.org/@agentclientprotocol/sdk/-/sdk-0.28.1.tgz",
|
||||
"integrity": "sha512-Z2Frs6YtPhnZZ+XwFXyQkRDXY0fn8FjCalEs0W4yUhQnY4TztmNq0/RnfzWdFN3vqT3h0jTz5klzYbZHGxCDyQ==",
|
||||
"license": "Apache-2.0",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.0 || ^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/ajv": {
|
||||
"version": "8.20.0",
|
||||
"resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz",
|
||||
@@ -1043,12 +1059,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/commander": {
|
||||
"version": "14.0.3",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz",
|
||||
"integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==",
|
||||
"version": "15.0.0",
|
||||
"resolved": "https://registry.npmjs.org/commander/-/commander-15.0.0.tgz",
|
||||
"integrity": "sha512-z67u4ZhzCL/Tydu1lJARtEZYWbWaN7oYLHbsuzocr6y4N6WZAagG3RQ4FW61V1/0+jImpj293XfrcYnd1qxtPg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
},
|
||||
"node_modules/content-disposition": {
|
||||
@@ -2045,9 +2061,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/skillflag": {
|
||||
"version": "0.1.4",
|
||||
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.1.4.tgz",
|
||||
"integrity": "sha512-egFg+XCF5sloOWdtzxZivTX7n4UDj5pxQoY33wbT8h+YSDjMQJ76MZUg2rXQIBXmIDtlZhLgirS1g/3R5/qaHA==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://registry.npmjs.org/skillflag/-/skillflag-0.2.0.tgz",
|
||||
"integrity": "sha512-7ZmEpBeEoPLc+hqZ/StAnCO/hulgEPANzPyZgOM/CZ5zc3b0ApSp3URavY5POM/OKyi5d9+UC/Q21OoiYC2kJw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@clack/prompts": "^1.0.1",
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
"dependencies": {
|
||||
"@agentclientprotocol/claude-agent-acp": "0.39.0",
|
||||
"@zed-industries/codex-acp": "0.15.0",
|
||||
"acpx": "0.10.0",
|
||||
"acpx": "0.11.2",
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -251,6 +251,15 @@ describe("prepareAcpxCodexAuthConfig", () => {
|
||||
expect(wrapper).not.toMatch(
|
||||
/forceKillTimer = setTimeout\(\(\) => killChildTree\("SIGKILL"\), 1_500\);\s*forceKillTimer\.unref\?\.\(\);\s*process\.exit\(1\);/s,
|
||||
);
|
||||
// Orphan detection must trigger on any PPID change, not only when the new
|
||||
// PPID is init (1). Systemd user services and container init reparent
|
||||
// orphaned processes to a session manager or container init (PID != 1),
|
||||
// and the older `process.ppid !== 1` guard would silently leak the codex
|
||||
// adapter tree there.
|
||||
expect(wrapper).not.toContain("process.ppid !== 1");
|
||||
expect(wrapper).toMatch(
|
||||
/setInterval\(\(\) => \{[\s\S]*?if \(process\.ppid === originalParentPid\) \{\s*return;\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the bundled Claude ACP dependency by default when it is installed", async () => {
|
||||
|
||||
@@ -475,7 +475,13 @@ const parentWatcher =
|
||||
process.platform === "win32"
|
||||
? undefined
|
||||
: setInterval(() => {
|
||||
if (process.ppid === originalParentPid || process.ppid !== 1) {
|
||||
// Orphan detection: parent PID changed means our original parent died.
|
||||
// The new parent could be PID 1 (init) on bare-metal hosts, OR a
|
||||
// systemd user-session manager, OR a container init, OR a session
|
||||
// leader — depending on environment. Previously this only triggered
|
||||
// on PPID == 1, which missed all systemd-managed deployments and
|
||||
// leaked codex-acp adapter trees on every gateway restart.
|
||||
if (process.ppid === originalParentPid) {
|
||||
return;
|
||||
}
|
||||
if (orphanCleanupStarted) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { RequestedModelUnsupportedError } from "acpx/runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
AcpRuntimeError,
|
||||
@@ -708,6 +709,100 @@ describe("AcpxRuntime fresh reset wrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("retries without a model when ACPX reports missing model capability", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore, {
|
||||
agentRegistry: {
|
||||
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
|
||||
list: () => ["opencode"],
|
||||
},
|
||||
});
|
||||
const ensure = vi
|
||||
.spyOn(delegate, "ensureSession")
|
||||
.mockRejectedValueOnce(
|
||||
new RequestedModelUnsupportedError(
|
||||
"Cannot apply --model: the ACP agent did not advertise model support",
|
||||
"missing-capability",
|
||||
),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
sessionKey: "agent:opencode:acp:test",
|
||||
backend: "acpx",
|
||||
runtimeSessionName: "opencode",
|
||||
});
|
||||
|
||||
await runtime.ensureSession({
|
||||
sessionKey: "agent:opencode:acp:test",
|
||||
agent: "opencode",
|
||||
mode: "persistent",
|
||||
model: "openrouter/owl-alpha",
|
||||
});
|
||||
|
||||
expect(ensure).toHaveBeenCalledTimes(2);
|
||||
expect(readFirstEnsureSessionInput(ensure)).toMatchObject({
|
||||
model: "openrouter/owl-alpha",
|
||||
sessionOptions: { model: "openrouter/owl-alpha" },
|
||||
});
|
||||
const [, secondCall] = ensure.mock.calls;
|
||||
expect(secondCall?.[0]).not.toHaveProperty("sessionOptions");
|
||||
expect((secondCall?.[0] as { model?: string } | undefined)?.model).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not retry when ACPX rejects an explicitly unsupported model id", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore, {
|
||||
agentRegistry: {
|
||||
resolve: (agentName: string) => (agentName === "opencode" ? "opencode acp" : agentName),
|
||||
list: () => ["opencode"],
|
||||
},
|
||||
});
|
||||
const ensure = vi
|
||||
.spyOn(delegate, "ensureSession")
|
||||
.mockRejectedValueOnce(
|
||||
new RequestedModelUnsupportedError(
|
||||
"Cannot apply --model: the ACP agent did not advertise that model",
|
||||
"unadvertised-model",
|
||||
),
|
||||
);
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:opencode:acp:test",
|
||||
agent: "opencode",
|
||||
mode: "persistent",
|
||||
model: "unknown/model",
|
||||
}),
|
||||
).rejects.toThrow("did not advertise that model");
|
||||
expect(ensure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry an unrelated error with similar wording", async () => {
|
||||
const baseStore: TestSessionStore = {
|
||||
load: vi.fn(async () => undefined),
|
||||
save: vi.fn(async () => {}),
|
||||
};
|
||||
const { runtime, delegate } = makeRuntime(baseStore);
|
||||
const ensure = vi
|
||||
.spyOn(delegate, "ensureSession")
|
||||
.mockRejectedValueOnce(new Error("the ACP agent did not advertise model support"));
|
||||
|
||||
await expect(
|
||||
runtime.ensureSession({
|
||||
sessionKey: "agent:main:acp:test",
|
||||
agent: "main",
|
||||
mode: "persistent",
|
||||
model: "openrouter/owl-alpha",
|
||||
}),
|
||||
).rejects.toThrow("did not advertise model support");
|
||||
expect(ensure).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("injects Codex ACP startup config into the scoped registry", () => {
|
||||
expect(testing.isCodexAcpCommand(CODEX_ACP_COMMAND)).toBe(true);
|
||||
expect(testing.isCodexAcpCommand(CODEX_ACP_WRAPPER_COMMAND)).toBe(true);
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
createFileSessionStore,
|
||||
decodeAcpxRuntimeHandleState,
|
||||
encodeAcpxRuntimeHandleState,
|
||||
isRequestedModelUnsupportedError,
|
||||
type AcpAgentRegistry,
|
||||
type AcpRuntimeDoctorReport,
|
||||
type AcpRuntimeEvent,
|
||||
@@ -586,6 +587,26 @@ function withAcpxSessionOptions(input: OpenClawRuntimeEnsureInput): AcpxDelegate
|
||||
} as AcpxDelegateEnsureInput;
|
||||
}
|
||||
|
||||
function isAcpModelCapabilityMissingError(error: unknown): boolean {
|
||||
return isRequestedModelUnsupportedError(error) && error.reason === "missing-capability";
|
||||
}
|
||||
|
||||
// ACPX owns the distinction between missing model capability and an invalid model id.
|
||||
// Retry only the former so explicit model mistakes remain visible to the caller.
|
||||
async function ensureDelegateSessionWithModelFallback(
|
||||
delegate: BaseAcpxRuntime,
|
||||
input: OpenClawRuntimeEnsureInput,
|
||||
): Promise<AcpRuntimeHandle> {
|
||||
try {
|
||||
return await delegate.ensureSession(withAcpxSessionOptions(input));
|
||||
} catch (error) {
|
||||
if (!input.model || !isAcpModelCapabilityMissingError(error)) {
|
||||
throw error;
|
||||
}
|
||||
return await delegate.ensureSession(withAcpxSessionOptions({ ...input, model: undefined }));
|
||||
}
|
||||
}
|
||||
|
||||
function quoteShellArg(value: string): string {
|
||||
if (/^[A-Za-z0-9_./:=@+-]+$/.test(value)) {
|
||||
return value;
|
||||
@@ -989,7 +1010,7 @@ export class AcpxRuntime implements AcpRuntime {
|
||||
this.withCodexWrapperDiagnostics({
|
||||
command: stableLaunchCommand,
|
||||
fallbackCode: "ACP_SESSION_INIT_FAILED",
|
||||
run: () => delegate.ensureSession(withAcpxSessionOptions(ensureInput)),
|
||||
run: () => ensureDelegateSessionWithModelFallback(delegate, ensureInput),
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "alibaba",
|
||||
"icon": "https://cdn.simpleicons.org/alibabacloud/111111",
|
||||
"icon": "https://cdn.simpleicons.org/alibabacloud",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "anthropic-vertex",
|
||||
"name": "Anthropic Vertex",
|
||||
"description": "OpenClaw Anthropic Vertex provider plugin for Claude models on Google Vertex AI.",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic/111111",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "anthropic",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic/111111",
|
||||
"icon": "https://cdn.simpleicons.org/anthropic",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "brave",
|
||||
"name": "Brave",
|
||||
"description": "OpenClaw Brave Search provider plugin for web search.",
|
||||
"icon": "https://cdn.simpleicons.org/brave/111111",
|
||||
"icon": "https://cdn.simpleicons.org/brave",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -43,23 +43,39 @@ afterAll(() => {
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
function malformedJsonResponse(): Response {
|
||||
return new Response("{ nope", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
});
|
||||
}
|
||||
|
||||
function emptyWebSearchResponse(): Response {
|
||||
return jsonResponse({ web: { results: [] } });
|
||||
}
|
||||
|
||||
function installBraveLlmContextFetch() {
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/context",
|
||||
title: "Context",
|
||||
snippets: ["snippet"],
|
||||
},
|
||||
],
|
||||
},
|
||||
sources: [],
|
||||
}),
|
||||
} as unknown as Response;
|
||||
return jsonResponse({
|
||||
grounding: {
|
||||
generic: [
|
||||
{
|
||||
url: "https://example.com/context",
|
||||
title: "Context",
|
||||
snippets: ["snippet"],
|
||||
},
|
||||
],
|
||||
},
|
||||
sources: [],
|
||||
});
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
return mockFetch;
|
||||
@@ -254,10 +270,7 @@ describe("brave web search provider", () => {
|
||||
it("uses configured Brave baseUrl for web search requests", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -310,12 +323,7 @@ describe("brave web search provider", () => {
|
||||
it("reports malformed Brave web search JSON as a provider error", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
} as unknown as Response;
|
||||
return malformedJsonResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -339,12 +347,7 @@ describe("brave web search provider", () => {
|
||||
it("reports malformed Brave llm-context JSON as a provider error", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => {
|
||||
throw new SyntaxError("Unexpected token");
|
||||
},
|
||||
} as unknown as Response;
|
||||
return malformedJsonResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -428,10 +431,7 @@ describe("brave web search provider", () => {
|
||||
it("keeps Brave cache entries isolated by baseUrl", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -573,10 +573,7 @@ describe("brave web search provider", () => {
|
||||
it("sends Brave web auth in the X-Subscription-Token header", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -732,10 +729,7 @@ describe("brave web search provider", () => {
|
||||
it("falls back unsupported country values before calling Brave", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "test-key");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
json: async () => ({ web: { results: [] } }),
|
||||
} as unknown as Response;
|
||||
return emptyWebSearchResponse();
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
@@ -763,21 +757,17 @@ describe("brave web search provider", () => {
|
||||
it("emits brave.http diagnostics for requests, responses, and cache events", async () => {
|
||||
vi.stubEnv("BRAVE_API_KEY", "");
|
||||
const mockFetch = vi.fn(async (_input?: unknown, _init?: unknown) => {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: "Diagnostics",
|
||||
url: "https://example.com/diagnostics",
|
||||
description: "debug details",
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
} as unknown as Response;
|
||||
return jsonResponse({
|
||||
web: {
|
||||
results: [
|
||||
{
|
||||
title: "Diagnostics",
|
||||
url: "https://example.com/diagnostics",
|
||||
description: "debug details",
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
});
|
||||
global.fetch = mockFetch as typeof global.fetch;
|
||||
|
||||
|
||||
@@ -15,8 +15,12 @@ import { resolvePnpmRunner } from "./pnpm-runner.mjs";
|
||||
const pluginDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const rootDir = path.resolve(pluginDir, "../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const hashFile = path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
|
||||
const outputFile = path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
|
||||
const hashFile =
|
||||
process.env.OPENCLAW_A2UI_BUNDLE_HASH_FILE ??
|
||||
path.join(pluginDir, "src", "host", "a2ui", ".bundle.hash");
|
||||
const outputFile =
|
||||
process.env.OPENCLAW_A2UI_BUNDLE_OUT ??
|
||||
path.join(pluginDir, "src", "host", "a2ui", "a2ui.bundle.js");
|
||||
const a2uiAppDir = path.join(pluginDir, "src", "host", "a2ui-app");
|
||||
const repoInputPaths = getBundleHashRepoInputPaths(rootDir);
|
||||
const relativeRepoInputPaths = repoInputPaths.map((inputPath) =>
|
||||
|
||||
@@ -11,7 +11,9 @@ const repoRoot = path.resolve(here, "../../../../..");
|
||||
const require = createRequire(import.meta.url);
|
||||
const uiRoot = path.resolve(repoRoot, "ui");
|
||||
const fromHere = (p) => path.resolve(here, p);
|
||||
const outputFile = path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
|
||||
const outputFile = process.env.OPENCLAW_A2UI_BUNDLE_OUT
|
||||
? path.resolve(process.env.OPENCLAW_A2UI_BUNDLE_OUT)
|
||||
: path.resolve(here, "..", "a2ui", "a2ui.bundle.js");
|
||||
|
||||
const a2uiLitIndex = require.resolve("@a2ui/lit");
|
||||
const a2uiLitUi = require.resolve("@a2ui/lit/ui");
|
||||
|
||||
@@ -15,6 +15,14 @@ function restoreEnvVar(name: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function runChutesCatalog(params: { apiKey?: string; discoveryApiKey?: string }) {
|
||||
const provider = await registerSingleProviderPlugin(plugin);
|
||||
const result = await provider.catalog?.run({
|
||||
@@ -44,10 +52,9 @@ async function withRealChutesDiscovery<T>(
|
||||
delete process.env.VITEST;
|
||||
delete process.env.NODE_ENV;
|
||||
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ data: [{ id: "chutes/private-model" }] }),
|
||||
});
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValue(jsonResponse({ data: [{ id: "chutes/private-model" }] }));
|
||||
globalThis.fetch = fetchMock as unknown as typeof fetch;
|
||||
|
||||
try {
|
||||
|
||||
@@ -15,6 +15,14 @@ function restoreEnvVar(name: string, value: string | undefined): void {
|
||||
}
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init: ResponseInit = {}): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
async function withLiveChutesDiscovery<T>(
|
||||
fetchMock: ReturnType<typeof vi.fn>,
|
||||
run: () => Promise<T>,
|
||||
@@ -45,12 +53,11 @@ async function withLiveChutesDiscovery<T>(
|
||||
function createAuthEchoFetchMock() {
|
||||
return vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
const auth = readAuthorizationHeader(init);
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: auth ? `${auth}-model` : "public-model" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -124,9 +131,8 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("discoverChutesModels correctly maps API response when not in test env", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{ id: "zai-org/GLM-4.7-TEE" },
|
||||
{
|
||||
@@ -140,7 +146,7 @@ describe("chutes-models", () => {
|
||||
{ id: "new-provider/simple-model" },
|
||||
],
|
||||
}),
|
||||
});
|
||||
);
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("test-token-real-fetch");
|
||||
expect(models.length).toBeGreaterThan(0);
|
||||
@@ -158,9 +164,8 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("falls back from malformed live token metadata", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
const mockFetch = vi.fn().mockResolvedValue(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "provider/bad-window",
|
||||
@@ -174,7 +179,7 @@ describe("chutes-models", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
);
|
||||
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("malformed-token-metadata");
|
||||
@@ -195,14 +200,10 @@ describe("chutes-models", () => {
|
||||
it("discoverChutesModels retries without auth on 401", async () => {
|
||||
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
if (readAuthorizationHeader(init) === "Bearer test-token-error") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
return Promise.resolve(new Response("", { status: 401 }));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [
|
||||
{
|
||||
id: "Qwen/Qwen3-32B",
|
||||
@@ -232,7 +233,7 @@ describe("chutes-models", () => {
|
||||
},
|
||||
],
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const models = await discoverChutesModels("test-token-error");
|
||||
@@ -242,10 +243,7 @@ describe("chutes-models", () => {
|
||||
});
|
||||
|
||||
it("does not cache fallback static catalog for non-OK responses", async () => {
|
||||
const mockFetch = vi.fn().mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
});
|
||||
const mockFetch = vi.fn().mockResolvedValue(new Response("", { status: 503 }));
|
||||
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const first = await discoverChutesModels("chutes-fallback-token");
|
||||
@@ -260,27 +258,24 @@ describe("chutes-models", () => {
|
||||
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
const auth = readAuthorizationHeader(init);
|
||||
if (auth === "Bearer chutes-token-a") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: "private/model-a" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
}
|
||||
if (auth === "Bearer chutes-token-b") {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: "private/model-b" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: "public/model" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
const modelsA = await discoverChutesModels("chutes-token-a");
|
||||
@@ -325,17 +320,13 @@ describe("chutes-models", () => {
|
||||
it("does not cache 401 fallback under the failed token key", async () => {
|
||||
const mockFetch = vi.fn().mockImplementation((_url, init?: { headers?: HeadersInit }) => {
|
||||
if (readAuthorizationHeader(init) === "Bearer failed-token") {
|
||||
return Promise.resolve({
|
||||
ok: false,
|
||||
status: 401,
|
||||
});
|
||||
return Promise.resolve(new Response("", { status: 401 }));
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
return Promise.resolve(
|
||||
jsonResponse({
|
||||
data: [{ id: "public/model" }],
|
||||
}),
|
||||
});
|
||||
);
|
||||
});
|
||||
await withLiveChutesDiscovery(mockFetch, async () => {
|
||||
await discoverChutesModels("failed-token");
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "cloudflare-ai-gateway",
|
||||
"icon": "https://cdn.simpleicons.org/cloudflare/111111",
|
||||
"icon": "https://cdn.simpleicons.org/cloudflare",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -78,13 +78,16 @@ type CodexWorkspaceBootstrapContext = CodexBootstrapContext & {
|
||||
};
|
||||
|
||||
/** Reads mirrored Codex session history for harness hooks. */
|
||||
export async function readMirroredSessionHistoryMessages(
|
||||
sessionFile: string,
|
||||
): Promise<AgentMessage[] | undefined> {
|
||||
const messages = await readCodexMirroredSessionHistoryMessages(sessionFile);
|
||||
export async function readMirroredSessionHistoryMessages(params: {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}): Promise<AgentMessage[] | undefined> {
|
||||
const messages = await readCodexMirroredSessionHistoryMessages(params);
|
||||
if (!messages) {
|
||||
embeddedAgentLog.warn("failed to read mirrored session history for codex harness hooks", {
|
||||
sessionFile,
|
||||
sessionFile: params.sessionFile,
|
||||
});
|
||||
}
|
||||
return messages;
|
||||
|
||||
@@ -1102,6 +1102,426 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("marks delivered message-tool-only source replies as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "imessage-6264" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when middleware redacts receipt details", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "receipt-redactor",
|
||||
pluginName: "Receipt redactor",
|
||||
rawHandler: () => undefined,
|
||||
handler: (event: { result: AgentToolResult<unknown> }) => ({
|
||||
result: {
|
||||
content: event.result.content,
|
||||
details: { redacted: true },
|
||||
},
|
||||
}),
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
receipt: {
|
||||
primaryPlatformMessageId: "imessage-6264",
|
||||
platformMessageIds: ["imessage-6264"],
|
||||
},
|
||||
}),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat target telemetry alone as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "chat-1",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "chat-1",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit current source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { ok: true, messageId: "imessage-853" }),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the reply receipt matches the current message id", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-857",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-857",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "857",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.messagingToolSentTargets).toEqual([
|
||||
expect.objectContaining({
|
||||
tool: "message",
|
||||
provider: "imessage",
|
||||
to: "+12069106512",
|
||||
text: "visible reply",
|
||||
}),
|
||||
]);
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when a text receipt matches the current message id", async () => {
|
||||
const receiptText = JSON.stringify({
|
||||
ok: true,
|
||||
messageId: "provider-message-1",
|
||||
repliedTo: "provider-guid-861",
|
||||
});
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult(receiptText), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
currentMessageId: "provider-guid-861",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "861",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText(receiptText));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal for explicit native target segments", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "863",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("keeps message-tool-only source replies terminal when the provider is only in the current channel id", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelId: "imessage:any;-;+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "865",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("records message-tool-owned terminal replies as delivered source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
{
|
||||
...textToolResult("Sent.", { ok: true }),
|
||||
terminate: true,
|
||||
} as AgentToolResult<unknown>,
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "+12069106512",
|
||||
messageId: "867",
|
||||
message: "visible reply",
|
||||
buttons: [],
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
expect(Object.keys(result)).not.toContain("terminate");
|
||||
});
|
||||
|
||||
it("does not treat bare send telemetry as delivered message-tool-only source reply evidence", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let prior message-send telemetry terminate a later non-delivery tool result", async () => {
|
||||
const execute = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(textToolResult("Sent.", { messageId: "source-reply-1" }))
|
||||
.mockResolvedValueOnce(textToolResult("No message sent.", { ok: true }));
|
||||
const bridge = createCodexDynamicToolBridge({
|
||||
tools: [createTool({ name: "message", execute })],
|
||||
signal: new AbortController().signal,
|
||||
hookContext: { sourceReplyDeliveryMode: "message_tool_only" },
|
||||
});
|
||||
|
||||
const firstResult = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
message: "visible reply",
|
||||
});
|
||||
const secondResult = await bridge.handleToolCall({
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
callId: "call-2",
|
||||
namespace: null,
|
||||
tool: "message",
|
||||
arguments: { action: "inspect" },
|
||||
});
|
||||
|
||||
expect(firstResult.terminate).toBe(true);
|
||||
expect(bridge.telemetry.didSendViaMessagingTool).toBe(true);
|
||||
expect(secondResult).toEqual(expectInputText("No message sent."));
|
||||
expect(secondResult.terminate).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not mark explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", { messageId: "other-chat-message" }),
|
||||
{ sourceReplyDeliveryMode: "message_tool_only" },
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "channel:other",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark mismatched explicit message-tool sends as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent."), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "+12069106512",
|
||||
messageId: "853",
|
||||
message: "cross-provider reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark same-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark implicit-target sibling-thread replies as terminal source replies", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
currentThreadId: "171.222",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
threadId: "171.333",
|
||||
message: "sibling thread reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not mark top-level source replies with explicit thread routes as terminal", async () => {
|
||||
const bridge = createBridgeWithToolResult("message", textToolResult("Sent.", { ok: true }), {
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "slack",
|
||||
currentChannelId: "slack:C123",
|
||||
currentMessagingTarget: "C123",
|
||||
});
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "slack",
|
||||
target: "C123",
|
||||
threadId: "171.333",
|
||||
message: "thread reply from top-level source",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not let matching reply receipts override explicit non-source routes", async () => {
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("Sent.", {
|
||||
ok: true,
|
||||
messageId: "other-chat-message",
|
||||
repliedTo: "provider-guid-853",
|
||||
}),
|
||||
{
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
currentChannelProvider: "imessage",
|
||||
currentChannelId: "imessage:+12069106512",
|
||||
currentMessagingTarget: "+12069106512",
|
||||
currentMessageId: "provider-guid-853",
|
||||
},
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "reply",
|
||||
channel: "imessage",
|
||||
target: "other-chat",
|
||||
message: "cross-channel reply",
|
||||
});
|
||||
|
||||
expect(result).toEqual(expectInputText("Sent."));
|
||||
expect(result.terminate).toBeUndefined();
|
||||
expect(bridge.telemetry.didDeliverSourceReplyViaMessageTool).toBe(false);
|
||||
});
|
||||
|
||||
it("does not record messaging side effects when the send fails", async () => {
|
||||
const tool = createTool({
|
||||
name: "message",
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
getChannelAgentToolMeta,
|
||||
getPluginToolMeta,
|
||||
type EmbeddedRunAttemptParams,
|
||||
isDeliveredMessageToolOnlySourceReplyResult,
|
||||
isReplaySafeToolCall,
|
||||
isToolWrappedWithBeforeToolCallHook,
|
||||
isToolResultError,
|
||||
@@ -63,9 +64,11 @@ type CodexDynamicToolHookContext = {
|
||||
currentChannelProvider?: string;
|
||||
currentChannelId?: string;
|
||||
currentMessagingTarget?: string;
|
||||
currentMessageId?: string | number;
|
||||
currentThreadId?: string;
|
||||
replyToMode?: "off" | "first" | "all" | "batched";
|
||||
hasRepliedRef?: { value: boolean };
|
||||
sourceReplyDeliveryMode?: EmbeddedRunAttemptParams["sourceReplyDeliveryMode"];
|
||||
onToolOutcome?: EmbeddedRunAttemptParams["onToolOutcome"];
|
||||
allocateToolOutcomeOrdinal?: EmbeddedRunAttemptParams["allocateToolOutcomeOrdinal"];
|
||||
};
|
||||
@@ -100,6 +103,218 @@ function applyCurrentMessageProvider(
|
||||
return { ...args, provider };
|
||||
}
|
||||
|
||||
function normalizeRouteToken(value: string | number | undefined): string | undefined {
|
||||
if (typeof value === "number") {
|
||||
return Number.isFinite(value) ? String(value) : undefined;
|
||||
}
|
||||
const normalized = value?.trim().toLowerCase();
|
||||
return normalized ? normalized : undefined;
|
||||
}
|
||||
|
||||
function sourceRouteTokens(hookContext: CodexDynamicToolHookContext | undefined): Set<string> {
|
||||
const tokens = new Set<string>();
|
||||
const currentTarget = normalizeRouteToken(hookContext?.currentMessagingTarget);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
if (currentTarget) {
|
||||
tokens.add(currentTarget);
|
||||
}
|
||||
if (currentChannel) {
|
||||
tokens.add(currentChannel);
|
||||
}
|
||||
const channelPrefixIndex = currentChannel?.indexOf(":") ?? -1;
|
||||
if (channelPrefixIndex >= 0 && currentChannel) {
|
||||
const unprefixedChannel = currentChannel.slice(channelPrefixIndex + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
for (const segment of unprefixedChannel.split(/[;,]/u)) {
|
||||
const token = normalizeRouteToken(segment);
|
||||
if (token) {
|
||||
tokens.add(token);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (currentProvider && currentChannel?.startsWith(`${currentProvider}:`)) {
|
||||
const unprefixedChannel = currentChannel.slice(currentProvider.length + 1);
|
||||
if (unprefixedChannel) {
|
||||
tokens.add(unprefixedChannel);
|
||||
}
|
||||
}
|
||||
return tokens;
|
||||
}
|
||||
|
||||
function routeTokenMatchesSource(
|
||||
token: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return normalized !== undefined && sourceRouteTokens(hookContext).has(normalized);
|
||||
}
|
||||
|
||||
function routeProviderMatchesSource(
|
||||
provider: string | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(provider);
|
||||
if (!normalized) {
|
||||
return false;
|
||||
}
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
const currentChannel = normalizeRouteToken(hookContext?.currentChannelId);
|
||||
return currentProvider === normalized || currentChannel?.startsWith(`${normalized}:`) === true;
|
||||
}
|
||||
|
||||
function routeTokenMatchesCurrentMessage(
|
||||
token: string | number | undefined,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
): boolean {
|
||||
const normalized = normalizeRouteToken(token);
|
||||
return (
|
||||
normalized !== undefined && normalized === normalizeRouteToken(hookContext?.currentMessageId)
|
||||
);
|
||||
}
|
||||
|
||||
function readRouteToken(record: Record<string, unknown>, key: string): string | number | undefined {
|
||||
const value = record[key];
|
||||
return typeof value === "string" || typeof value === "number" ? value : undefined;
|
||||
}
|
||||
|
||||
function explicitRouteTokensMismatchCurrent(
|
||||
args: Record<string, unknown>,
|
||||
keys: readonly string[],
|
||||
currentToken: string | number | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrent = normalizeRouteToken(currentToken);
|
||||
if (!normalizedCurrent) {
|
||||
return false;
|
||||
}
|
||||
return keys.some((key) => {
|
||||
const normalized = normalizeRouteToken(readRouteToken(args, key));
|
||||
return normalized !== undefined && normalized !== normalizedCurrent;
|
||||
});
|
||||
}
|
||||
|
||||
function explicitThreadRouteTargetsNonSource(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const normalizedCurrentThread = normalizeRouteToken(hookContext?.currentThreadId);
|
||||
const explicitThreadTokens = [
|
||||
...EXPLICIT_MESSAGE_THREAD_KEYS.map((key) => normalizeRouteToken(readRouteToken(args, key))),
|
||||
normalizeRouteToken(messagingTarget?.threadId),
|
||||
].filter((value): value is string => value !== undefined);
|
||||
|
||||
if (explicitThreadTokens.length === 0) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
normalizedCurrentThread === undefined ||
|
||||
explicitThreadTokens.some((value) => value !== normalizedCurrentThread)
|
||||
);
|
||||
}
|
||||
|
||||
function replyReceiptMatchesCurrentMessage(
|
||||
value: unknown,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
depth = 0,
|
||||
): boolean {
|
||||
if (depth > 4 || value === null) {
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "string") {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed || !["{", "["].includes(trimmed[0] ?? "")) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
return replyReceiptMatchesCurrentMessage(JSON.parse(trimmed), hookContext, depth + 1);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
if (typeof value !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.some((item) => replyReceiptMatchesCurrentMessage(item, hookContext, depth + 1));
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
for (const key of ["repliedTo", "replyTo", "replyToId", "replyToIdFull"]) {
|
||||
if (
|
||||
routeTokenMatchesCurrentMessage(
|
||||
typeof record[key] === "string" ? record[key] : undefined,
|
||||
hookContext,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
for (const key of [
|
||||
"content",
|
||||
"details",
|
||||
"payload",
|
||||
"receipt",
|
||||
"result",
|
||||
"results",
|
||||
"sendResult",
|
||||
"text",
|
||||
]) {
|
||||
if (replyReceiptMatchesCurrentMessage(record[key], hookContext, depth + 1)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function hasExplicitNonSourceMessageRoute(
|
||||
args: Record<string, unknown>,
|
||||
hookContext: CodexDynamicToolHookContext | undefined,
|
||||
messagingTarget: MessagingToolSend | undefined,
|
||||
): boolean {
|
||||
const currentProvider = normalizeRouteToken(hookContext?.currentChannelProvider);
|
||||
for (const key of EXPLICIT_MESSAGE_PROVIDER_KEYS) {
|
||||
const provider = normalizeRouteToken(typeof args[key] === "string" ? args[key] : undefined);
|
||||
if (
|
||||
provider &&
|
||||
currentProvider !== provider &&
|
||||
!routeProviderMatchesSource(provider, hookContext)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const targetValues = [
|
||||
...EXPLICIT_MESSAGE_TARGET_KEYS.map((key) =>
|
||||
typeof args[key] === "string" ? args[key] : undefined,
|
||||
),
|
||||
...(Array.isArray(args.targets)
|
||||
? args.targets.map((value) => (typeof value === "string" ? value : undefined))
|
||||
: []),
|
||||
].filter((value): value is string => normalizeRouteToken(value) !== undefined);
|
||||
if (explicitThreadRouteTargetsNonSource(args, hookContext, messagingTarget)) {
|
||||
return true;
|
||||
}
|
||||
if (
|
||||
explicitRouteTokensMismatchCurrent(
|
||||
args,
|
||||
EXPLICIT_MESSAGE_REPLY_KEYS,
|
||||
hookContext?.currentMessageId,
|
||||
)
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
if (targetValues.length === 0) {
|
||||
return false;
|
||||
}
|
||||
if (targetValues.some((value) => !routeTokenMatchesSource(value, hookContext))) {
|
||||
return true;
|
||||
}
|
||||
return (
|
||||
messagingTarget?.to !== undefined && !routeTokenMatchesSource(messagingTarget.to, hookContext)
|
||||
);
|
||||
}
|
||||
|
||||
/** Runtime bridge returned to Codex app-server attempt code. */
|
||||
export type CodexDynamicToolBridge = {
|
||||
availableSpecs: CodexDynamicToolSpec[];
|
||||
@@ -114,6 +329,7 @@ export type CodexDynamicToolBridge = {
|
||||
) => Promise<CodexDynamicToolCallResponse>;
|
||||
telemetry: {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -132,6 +348,10 @@ export const CODEX_OPENCLAW_DYNAMIC_TOOL_NAMESPACE = "openclaw";
|
||||
// Keep OpenClaw session spawning searchable in Codex mode so Codex's native
|
||||
// spawn_agent remains the primary Codex subagent surface.
|
||||
const ALWAYS_DIRECT_DYNAMIC_TOOL_NAMES = new Set(["sessions_yield"]);
|
||||
const EXPLICIT_MESSAGE_PROVIDER_KEYS = ["channel", "provider"];
|
||||
const EXPLICIT_MESSAGE_TARGET_KEYS = ["target", "to", "channelId"];
|
||||
const EXPLICIT_MESSAGE_THREAD_KEYS = ["threadId", "thread_id", "messageThreadId", "topicId"];
|
||||
const EXPLICIT_MESSAGE_REPLY_KEYS = ["replyTo", "replyToId", "replyToIdFull"];
|
||||
const DEFAULT_CODEX_DYNAMIC_TOOL_RESULT_MAX_CHARS = 16_000;
|
||||
|
||||
/**
|
||||
@@ -176,6 +396,7 @@ export function createCodexDynamicToolBridge(params: {
|
||||
emitQuarantinedDynamicToolDiagnostics(quarantinedTools, params.hookContext);
|
||||
const telemetry: CodexDynamicToolBridge["telemetry"] = {
|
||||
didSendViaMessagingTool: false,
|
||||
didDeliverSourceReplyViaMessageTool: false,
|
||||
messagingToolSentTexts: [],
|
||||
messagingToolSentMediaUrls: [],
|
||||
messagingToolSentTargets: [],
|
||||
@@ -333,10 +554,9 @@ export function createCodexDynamicToolBridge(params: {
|
||||
executedArgs,
|
||||
params.hookContext?.currentChannelProvider,
|
||||
);
|
||||
const messagingTarget =
|
||||
isMessagingTool(toolName) && isMessagingToolSendAction(toolName, executedArgs)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const messagingTarget = isMessagingTool(toolName)
|
||||
? extractMessagingToolSend(toolName, messagingTelemetryArgs, messagingContext)
|
||||
: undefined;
|
||||
const confirmedMessagingTarget =
|
||||
!rawIsError && messagingTarget
|
||||
? extractMessagingToolSendResult(messagingTarget, telemetryRawResult)
|
||||
@@ -358,12 +578,46 @@ export function createCodexDynamicToolBridge(params: {
|
||||
},
|
||||
terminalType,
|
||||
);
|
||||
const blocksSourceReplyTermination = hasExplicitNonSourceMessageRoute(
|
||||
executedArgs,
|
||||
params.hookContext,
|
||||
confirmedMessagingTarget,
|
||||
);
|
||||
const deliveredSourceReply = isDeliveredMessageToolOnlySourceReplyResult({
|
||||
sourceReplyDeliveryMode: params.hookContext?.sourceReplyDeliveryMode,
|
||||
toolName,
|
||||
args: executedArgs,
|
||||
result,
|
||||
hookResult: rawResult,
|
||||
isError: resultIsError,
|
||||
allowExplicitSourceRoute: !blocksSourceReplyTermination,
|
||||
});
|
||||
const receiptConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
normalizeRouteToken(
|
||||
typeof executedArgs.action === "string" ? executedArgs.action : undefined,
|
||||
) === "reply" &&
|
||||
!resultIsError &&
|
||||
!blocksSourceReplyTermination &&
|
||||
(replyReceiptMatchesCurrentMessage(rawResult, params.hookContext) ||
|
||||
replyReceiptMatchesCurrentMessage(result, params.hookContext));
|
||||
const toolConfirmedSourceReply =
|
||||
params.hookContext?.sourceReplyDeliveryMode === "message_tool_only" &&
|
||||
toolName === "message" &&
|
||||
!resultIsError &&
|
||||
(rawResult.terminate === true || result.terminate === true);
|
||||
if (deliveredSourceReply || receiptConfirmedSourceReply || toolConfirmedSourceReply) {
|
||||
telemetry.didDeliverSourceReplyViaMessageTool = true;
|
||||
}
|
||||
withDynamicToolTermination(
|
||||
response,
|
||||
rawResult.terminate === true ||
|
||||
result.terminate === true ||
|
||||
isToolResultYield(rawResult) ||
|
||||
isToolResultYield(result),
|
||||
isToolResultYield(result) ||
|
||||
deliveredSourceReply ||
|
||||
receiptConfirmedSourceReply,
|
||||
);
|
||||
const asyncStarted =
|
||||
isAsyncStartedToolResult(rawResult) || isAsyncStartedToolResult(result);
|
||||
@@ -803,7 +1057,7 @@ function collectToolTelemetry(params: {
|
||||
}
|
||||
if (
|
||||
!isMessagingTool(params.toolName) ||
|
||||
!isMessagingToolSendAction(params.toolName, params.args)
|
||||
(!isMessagingToolSendAction(params.toolName, params.args) && !params.messagingTarget)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -794,6 +794,19 @@ describe("CodexAppServerEventProjector", () => {
|
||||
expect(result.toolMediaUrls).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("propagates message-tool-only source reply delivery telemetry", async () => {
|
||||
const projector = await createProjector();
|
||||
|
||||
const result = projector.buildResult({
|
||||
...buildEmptyToolTelemetry(),
|
||||
didSendViaMessagingTool: true,
|
||||
didDeliverSourceReplyViaMessageTool: true,
|
||||
});
|
||||
|
||||
expect(result.didSendViaMessagingTool).toBe(true);
|
||||
expect(result.didDeliverSourceReplyViaMessageTool).toBe(true);
|
||||
});
|
||||
|
||||
it("does not promote repeated tool progress text to the final assistant reply", async () => {
|
||||
const onToolResult = vi.fn();
|
||||
const projector = await createProjector({
|
||||
|
||||
@@ -53,6 +53,7 @@ import { attachCodexMirrorIdentity, buildCodexUserPromptMessage } from "./transc
|
||||
|
||||
export type CodexAppServerToolTelemetry = {
|
||||
didSendViaMessagingTool: boolean;
|
||||
didDeliverSourceReplyViaMessageTool?: boolean;
|
||||
messagingToolSentTexts: string[];
|
||||
messagingToolSentMediaUrls: string[];
|
||||
messagingToolSentTargets: MessagingToolSend[];
|
||||
@@ -412,6 +413,8 @@ export class CodexAppServerEventProjector {
|
||||
currentAttemptAssistant,
|
||||
...(this.lastNativeToolError ? { lastToolError: this.lastNativeToolError } : {}),
|
||||
didSendViaMessagingTool: toolTelemetry.didSendViaMessagingTool,
|
||||
didDeliverSourceReplyViaMessageTool:
|
||||
toolTelemetry.didDeliverSourceReplyViaMessageTool === true,
|
||||
messagingToolSentTexts: toolTelemetry.messagingToolSentTexts,
|
||||
messagingToolSentMediaUrls: toolTelemetry.messagingToolSentMediaUrls,
|
||||
messagingToolSentTargets: toolTelemetry.messagingToolSentTargets,
|
||||
@@ -1827,7 +1830,14 @@ export class CodexAppServerEventProjector {
|
||||
}
|
||||
|
||||
private async readMirroredSessionMessages(): Promise<AgentMessage[]> {
|
||||
return (await readCodexMirroredSessionHistoryMessages(this.params.sessionFile)) ?? [];
|
||||
return (
|
||||
(await readCodexMirroredSessionHistoryMessages({
|
||||
agentId: this.params.agentId,
|
||||
sessionFile: this.params.sessionFile,
|
||||
sessionId: this.params.sessionId,
|
||||
sessionKey: this.params.sessionKey,
|
||||
})) ?? []
|
||||
);
|
||||
}
|
||||
|
||||
private createAssistantMessage(text: string): AssistantMessage {
|
||||
|
||||
@@ -841,15 +841,26 @@ export async function runCodexAppServerAttempt(
|
||||
currentChannelProvider: resolveCodexMessageToolProvider(params),
|
||||
currentChannelId: params.currentChannelId,
|
||||
currentMessagingTarget: params.currentMessagingTarget,
|
||||
currentMessageId: params.currentMessageId,
|
||||
currentThreadId: params.currentThreadTs,
|
||||
replyToMode: params.replyToMode,
|
||||
hasRepliedRef: params.hasRepliedRef,
|
||||
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
|
||||
onToolOutcome: onCodexToolOutcome,
|
||||
allocateToolOutcomeOrdinal: allocateCodexToolOutcomeOrdinal,
|
||||
},
|
||||
});
|
||||
const hadSessionFile = await pathExists(activeSessionFile);
|
||||
let historyMessages = (await readMirroredSessionHistoryMessages(activeSessionFile)) ?? [];
|
||||
const activeTranscriptTarget = {
|
||||
agentId: sessionAgentId,
|
||||
sessionFile: activeSessionFile,
|
||||
sessionId: activeSessionId,
|
||||
sessionKey: contextSessionKey,
|
||||
};
|
||||
let historyMessages =
|
||||
!activeContextEngine && initialStartupBindingHadInactiveThreadBootstrap
|
||||
? []
|
||||
: ((await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? []);
|
||||
const hookContextWindowFields = {
|
||||
...(params.contextWindowInfo?.tokens
|
||||
? { contextTokenBudget: params.contextWindowInfo.tokens }
|
||||
@@ -907,7 +918,7 @@ export async function runCodexAppServerAttempt(
|
||||
warn: (message) => embeddedAgentLog.warn(message),
|
||||
});
|
||||
historyMessages =
|
||||
(await readMirroredSessionHistoryMessages(activeSessionFile)) ?? historyMessages;
|
||||
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? historyMessages;
|
||||
}
|
||||
const memoryToolNames = getCodexWorkspaceMemoryToolNames(toolBridge.availableSpecs);
|
||||
const workspaceBootstrapContext = await buildCodexWorkspaceBootstrapContext({
|
||||
@@ -3039,7 +3050,7 @@ export async function runCodexAppServerAttempt(
|
||||
const activeContextEnginePluginIdLocal =
|
||||
resolveContextEngineOwnerPluginId(activeContextEngine);
|
||||
const finalMessages =
|
||||
(await readMirroredSessionHistoryMessages(activeSessionFile)) ??
|
||||
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ??
|
||||
historyMessages.concat(result.messagesSnapshot);
|
||||
await finalizeHarnessContextEngineTurn({
|
||||
contextEngine: activeContextEngine,
|
||||
|
||||
@@ -51,6 +51,14 @@ function messageEntry(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function mirroredTarget(sessionFile: string) {
|
||||
return {
|
||||
sessionFile,
|
||||
sessionId: "codex-session",
|
||||
sessionKey: "codex-session",
|
||||
};
|
||||
}
|
||||
|
||||
describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
it("replays only the branch selected by a leaf control", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
@@ -75,7 +83,9 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
await expect(
|
||||
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
|
||||
).resolves.toMatchObject([
|
||||
{ role: "user", content: "root prompt" },
|
||||
{ role: "assistant", content: "active answer" },
|
||||
]);
|
||||
@@ -93,7 +103,9 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toEqual([]);
|
||||
await expect(
|
||||
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
|
||||
).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps visible history when continuation rows use a disjoint append cursor", async () => {
|
||||
@@ -125,7 +137,9 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
await expect(
|
||||
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
|
||||
).resolves.toMatchObject([
|
||||
{ role: "user", content: "visible prompt" },
|
||||
{ role: "assistant", content: "continued answer" },
|
||||
]);
|
||||
@@ -154,7 +168,9 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
await expect(
|
||||
readCodexMirroredSessionHistoryMessages(mirroredTarget(sessionFile)),
|
||||
).resolves.toMatchObject([
|
||||
{ role: "user", content: "visible prompt" },
|
||||
{ role: "assistant", content: "continued answer" },
|
||||
]);
|
||||
|
||||
@@ -10,40 +10,59 @@ import {
|
||||
migrateSessionEntries,
|
||||
parseSessionEntries,
|
||||
} from "openclaw/plugin-sdk/agent-sessions";
|
||||
import {
|
||||
resolveSessionTranscriptTarget,
|
||||
type SessionTranscriptTargetParams,
|
||||
} from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import { sanitizeCodexHistoryImagePayloads } from "./image-payload-sanitizer.js";
|
||||
|
||||
function isMissingFileError(error: unknown): boolean {
|
||||
return Boolean(
|
||||
error &&
|
||||
typeof error === "object" &&
|
||||
"code" in error &&
|
||||
(error as { code?: unknown }).code === "ENOENT",
|
||||
);
|
||||
}
|
||||
export type CodexMirroredSessionHistoryTarget = {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
};
|
||||
|
||||
/** Returns sanitized session-context messages for a Codex mirrored session file. */
|
||||
export async function readCodexMirroredSessionHistoryMessages(
|
||||
sessionFile: string,
|
||||
target: CodexMirroredSessionHistoryTarget,
|
||||
): Promise<AgentMessage[] | undefined> {
|
||||
try {
|
||||
const raw = await fs.readFile(sessionFile, "utf-8");
|
||||
await resolveSessionTranscriptTarget(resolveCodexHistoryTranscriptTarget(target));
|
||||
const raw = await fs.readFile(target.sessionFile, "utf-8");
|
||||
const entries = parseSessionEntries(raw);
|
||||
if (entries.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const firstEntry = entries[0] as { type?: unknown; id?: unknown } | undefined;
|
||||
if (firstEntry?.type !== "session" || typeof firstEntry.id !== "string") {
|
||||
return undefined;
|
||||
}
|
||||
migrateSessionEntries(entries);
|
||||
const sessionEntries = entries.filter(
|
||||
(entry): entry is SessionEntry => entry.type !== "session",
|
||||
);
|
||||
migrateSessionEntries(entries as SessionEntry[]);
|
||||
const sessionEntries = entries.filter((entry): entry is SessionEntry => {
|
||||
return (
|
||||
entry !== null &&
|
||||
typeof entry === "object" &&
|
||||
!Array.isArray(entry) &&
|
||||
(entry as { type?: unknown }).type !== "session"
|
||||
);
|
||||
});
|
||||
return sanitizeCodexHistoryImagePayloads(
|
||||
buildSessionContext(sessionEntries).messages,
|
||||
"codex mirrored history",
|
||||
);
|
||||
} catch (error) {
|
||||
if (isMissingFileError(error)) {
|
||||
return [];
|
||||
}
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
function resolveCodexHistoryTranscriptTarget(
|
||||
target: CodexMirroredSessionHistoryTarget,
|
||||
): SessionTranscriptTargetParams {
|
||||
return {
|
||||
...(target.agentId ? { agentId: target.agentId } : {}),
|
||||
sessionFile: target.sessionFile,
|
||||
sessionId: target.sessionId,
|
||||
sessionKey: target.sessionKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -21,13 +21,14 @@ import {
|
||||
mirrorCodexAppServerTranscript,
|
||||
} from "./transcript-mirror.js";
|
||||
|
||||
const emitSessionTranscriptUpdateMock = vi.hoisted(() => vi.fn());
|
||||
const publishSessionTranscriptUpdateByIdentityMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/agent-harness-runtime", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/agent-harness-runtime")>();
|
||||
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
|
||||
const actual =
|
||||
await importOriginal<typeof import("openclaw/plugin-sdk/session-transcript-runtime")>();
|
||||
return {
|
||||
...actual,
|
||||
emitSessionTranscriptUpdate: emitSessionTranscriptUpdateMock,
|
||||
publishSessionTranscriptUpdateByIdentity: publishSessionTranscriptUpdateByIdentityMock,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -44,7 +45,7 @@ const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
resetGlobalHookRunner();
|
||||
emitSessionTranscriptUpdateMock.mockReset();
|
||||
publishSessionTranscriptUpdateByIdentityMock.mockReset();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
@@ -130,6 +131,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, assistantMessage, toolResultMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -164,30 +166,32 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
const firstMirror = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [userMessage],
|
||||
idempotencyScope: "codex-app-server:thread-1",
|
||||
});
|
||||
const secondMirror = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [userMessage],
|
||||
idempotencyScope: "codex-app-server:thread-1",
|
||||
});
|
||||
|
||||
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
|
||||
([update]) => update as Record<string, unknown>,
|
||||
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
|
||||
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
|
||||
);
|
||||
expect(updates).toHaveLength(1);
|
||||
expect(updates[0]?.sessionFile).toBe(sessionFile);
|
||||
expect(updates[0]?.sessionKey).toBe("agent:main:main");
|
||||
expect(updates[0]?.messageId).toEqual(expect.any(String));
|
||||
expect(updates[0]?.message).toMatchObject({
|
||||
expect(updates[0]?.update?.messageId).toEqual(expect.any(String));
|
||||
expect(updates[0]?.update?.message).toMatchObject({
|
||||
role: "user",
|
||||
content: [{ type: "text", text: "show me live" }],
|
||||
idempotencyKey: "codex-app-server:thread-1:turn-1:prompt",
|
||||
});
|
||||
expect(updates[0]?.messageSeq).toBe(1);
|
||||
expect(updates[0]?.update?.messageSeq).toBe(1);
|
||||
expect(firstMirror.userMessagesPresent).toHaveLength(1);
|
||||
expect(firstMirror.userMessagesPresent[0]).toMatchObject({
|
||||
role: "user",
|
||||
@@ -207,6 +211,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:main",
|
||||
messages: [
|
||||
attachCodexMirrorIdentity(
|
||||
@@ -227,14 +232,16 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
idempotencyScope: "codex-app-server:thread-1",
|
||||
});
|
||||
|
||||
const updates = emitSessionTranscriptUpdateMock.mock.calls.map(
|
||||
([update]) => update as Record<string, unknown>,
|
||||
const updates = publishSessionTranscriptUpdateByIdentityMock.mock.calls.map(
|
||||
([update]) => update as Record<string, unknown> & { update?: Record<string, unknown> },
|
||||
);
|
||||
expect(updates.map((update) => update.messageSeq)).toEqual([1, 2]);
|
||||
expect(updates.map((update) => (update.message as { role?: string }).role)).toEqual([
|
||||
"user",
|
||||
"assistant",
|
||||
]);
|
||||
expect(updates.map((update) => update.update?.messageSeq)).toEqual([1, 2]);
|
||||
expect(
|
||||
updates.map((update) => {
|
||||
const message = update.update?.message as { role?: string } | undefined;
|
||||
return message?.role;
|
||||
}),
|
||||
).toEqual(["user", "assistant"]);
|
||||
});
|
||||
|
||||
it("creates the transcript directory on first mirror", async () => {
|
||||
@@ -243,6 +250,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
@@ -273,12 +281,14 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [...messages],
|
||||
idempotencyScope: "scope-1",
|
||||
});
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [...messages],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -312,6 +322,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -348,12 +359,14 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
const first = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
});
|
||||
const second = await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -394,6 +407,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
@@ -419,6 +433,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
@@ -456,6 +471,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
@@ -534,6 +550,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, assistantMessage],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -547,6 +564,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
);
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, reasoningMessage, assistantMessage],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -595,12 +613,14 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userTurn1, assistantTurn1],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
});
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userTurn2, assistantTurn2],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -638,6 +658,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
);
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userTurn1, assistantTurn1],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -661,6 +682,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
// turn 1's entries (with their original identities preserved).
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userTurn1, assistantTurn1, userTurn2, assistantTurn2],
|
||||
idempotencyScope: "codex-app-server:thread-X",
|
||||
@@ -691,6 +713,7 @@ describe("mirrorCodexAppServerTranscript", () => {
|
||||
|
||||
await mirrorCodexAppServerTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, assistantMessage],
|
||||
idempotencyScope: "scope-1",
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
// Codex plugin module implements transcript mirror behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import {
|
||||
acquireSessionWriteLock,
|
||||
appendSessionTranscriptMessage,
|
||||
embeddedAgentLog,
|
||||
emitSessionTranscriptUpdate,
|
||||
formatErrorMessage,
|
||||
resolveSessionWriteLockOptions,
|
||||
runAgentHarnessBeforeMessageWriteHook,
|
||||
type AgentMessage,
|
||||
type EmbeddedRunAttemptParams,
|
||||
type EmbeddedRunAttemptResult,
|
||||
type SessionWriteLockAcquireTimeoutConfig,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
publishSessionTranscriptUpdateByIdentity,
|
||||
withSessionTranscriptWriteLock,
|
||||
type SessionTranscriptTargetParams,
|
||||
type SessionTranscriptWriteLockParams,
|
||||
} from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
||||
@@ -273,13 +273,13 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
|
||||
|
||||
export async function mirrorCodexAppServerTranscript(params: {
|
||||
sessionFile: string;
|
||||
sessionId?: string;
|
||||
sessionId: string;
|
||||
cwd?: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
messages: AgentMessage[];
|
||||
idempotencyScope?: string;
|
||||
config?: SessionWriteLockAcquireTimeoutConfig;
|
||||
config?: SessionTranscriptWriteLockParams["config"];
|
||||
}): Promise<CodexAppServerTranscriptMirrorResult> {
|
||||
const messages = params.messages.filter(
|
||||
(message): message is MirroredAgentMessage =>
|
||||
@@ -289,120 +289,133 @@ export async function mirrorCodexAppServerTranscript(params: {
|
||||
return { userMessagesPresent: [] };
|
||||
}
|
||||
|
||||
const lock = await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
...resolveSessionWriteLockOptions(params.config),
|
||||
});
|
||||
const appendedUpdates: Array<{ messageId: string; message: AgentMessage; messageSeq: number }> =
|
||||
[];
|
||||
const userMessagesPresent: MirroredUserMessage[] = [];
|
||||
try {
|
||||
const mirrorState = await readTranscriptMirrorState(params.sessionFile);
|
||||
let nextMessageSeq = mirrorState.messageCount;
|
||||
for (const message of messages) {
|
||||
const dedupeIdentity = buildMirrorDedupeIdentity(message);
|
||||
const idempotencyKey = params.idempotencyScope
|
||||
? `${params.idempotencyScope}:${dedupeIdentity}`
|
||||
: undefined;
|
||||
const transcriptMessage = {
|
||||
...message,
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
} as AgentMessage;
|
||||
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
|
||||
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
|
||||
if (persistedUserMessage) {
|
||||
userMessagesPresent.push(persistedUserMessage);
|
||||
const transcriptTarget = resolveCodexMirrorTranscriptTarget(params);
|
||||
const { appendedUpdates, userMessagesPresent } = await withSessionTranscriptWriteLock(
|
||||
{ ...transcriptTarget, config: params.config },
|
||||
async (transcript) => {
|
||||
const nextAppendedUpdates: Array<{
|
||||
messageId: string;
|
||||
message: AgentMessage;
|
||||
messageSeq: number;
|
||||
}> = [];
|
||||
const nextUserMessagesPresent: MirroredUserMessage[] = [];
|
||||
const mirrorState = readTranscriptMirrorState(await transcript.readEvents());
|
||||
let nextMessageSeq = mirrorState.messageCount;
|
||||
for (const message of messages) {
|
||||
const dedupeIdentity = buildMirrorDedupeIdentity(message);
|
||||
const idempotencyKey = params.idempotencyScope
|
||||
? `${params.idempotencyScope}:${dedupeIdentity}`
|
||||
: undefined;
|
||||
const transcriptMessage = {
|
||||
...message,
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
} as AgentMessage;
|
||||
if (idempotencyKey && mirrorState.idempotencyKeys.has(idempotencyKey)) {
|
||||
const persistedUserMessage = mirrorState.userMessagesByIdempotencyKey.get(idempotencyKey);
|
||||
if (persistedUserMessage) {
|
||||
nextUserMessagesPresent.push(persistedUserMessage);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
|
||||
message: transcriptMessage,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!nextMessage) {
|
||||
continue;
|
||||
}
|
||||
const messageToAppend = (
|
||||
idempotencyKey
|
||||
? {
|
||||
...(nextMessage as unknown as Record<string, unknown>),
|
||||
idempotencyKey,
|
||||
}
|
||||
: nextMessage
|
||||
) as AgentMessage;
|
||||
const { messageId, message: appendedMessage } = await appendSessionTranscriptMessage({
|
||||
transcriptPath: params.sessionFile,
|
||||
message: messageToAppend,
|
||||
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
|
||||
sessionId: params.sessionId,
|
||||
cwd: params.cwd,
|
||||
config: params.config,
|
||||
});
|
||||
if (appendedMessage.role === "user") {
|
||||
userMessagesPresent.push(appendedMessage);
|
||||
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
|
||||
message: transcriptMessage,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!nextMessage) {
|
||||
continue;
|
||||
}
|
||||
const messageToAppend = (
|
||||
idempotencyKey
|
||||
? {
|
||||
...(nextMessage as unknown as Record<string, unknown>),
|
||||
idempotencyKey,
|
||||
}
|
||||
: nextMessage
|
||||
) as AgentMessage;
|
||||
const appended = await transcript.appendMessage({
|
||||
message: messageToAppend,
|
||||
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
|
||||
cwd: params.cwd,
|
||||
});
|
||||
if (!appended) {
|
||||
continue;
|
||||
}
|
||||
const { messageId, message: appendedMessage } = appended;
|
||||
if (appendedMessage.role === "user") {
|
||||
nextUserMessagesPresent.push(appendedMessage);
|
||||
if (idempotencyKey) {
|
||||
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
|
||||
}
|
||||
}
|
||||
nextMessageSeq += 1;
|
||||
nextAppendedUpdates.push({
|
||||
messageId,
|
||||
message: appendedMessage,
|
||||
messageSeq: nextMessageSeq,
|
||||
});
|
||||
if (idempotencyKey) {
|
||||
mirrorState.userMessagesByIdempotencyKey.set(idempotencyKey, appendedMessage);
|
||||
mirrorState.idempotencyKeys.add(idempotencyKey);
|
||||
}
|
||||
}
|
||||
nextMessageSeq += 1;
|
||||
appendedUpdates.push({ messageId, message: appendedMessage, messageSeq: nextMessageSeq });
|
||||
if (idempotencyKey) {
|
||||
mirrorState.idempotencyKeys.add(idempotencyKey);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
return { appendedUpdates: nextAppendedUpdates, userMessagesPresent: nextUserMessagesPresent };
|
||||
},
|
||||
);
|
||||
|
||||
for (const update of appendedUpdates) {
|
||||
emitSessionTranscriptUpdate({
|
||||
sessionFile: params.sessionFile,
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
message: update.message,
|
||||
messageId: update.messageId,
|
||||
messageSeq: update.messageSeq,
|
||||
await publishSessionTranscriptUpdateByIdentity({
|
||||
...transcriptTarget,
|
||||
update: {
|
||||
...(params.sessionKey ? { sessionKey: params.sessionKey } : {}),
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
message: update.message,
|
||||
messageId: update.messageId,
|
||||
messageSeq: update.messageSeq,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
return { userMessagesPresent };
|
||||
}
|
||||
|
||||
async function readTranscriptMirrorState(sessionFile: string): Promise<{
|
||||
function resolveCodexMirrorTranscriptTarget(params: {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}): SessionTranscriptTargetParams {
|
||||
return {
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
sessionFile: params.sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function readTranscriptMirrorState(events: unknown[]): {
|
||||
idempotencyKeys: Set<string>;
|
||||
messageCount: number;
|
||||
userMessagesByIdempotencyKey: Map<string, MirroredUserMessage>;
|
||||
}> {
|
||||
} {
|
||||
const idempotencyKeys = new Set<string>();
|
||||
const userMessagesByIdempotencyKey = new Map<string, MirroredUserMessage>();
|
||||
let messageCount = 0;
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(sessionFile, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
|
||||
}
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
for (const event of events) {
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { message?: AgentMessage & { idempotencyKey?: unknown } };
|
||||
if ((parsed as { type?: unknown }).type === "message") {
|
||||
messageCount += 1;
|
||||
const parsed = event as {
|
||||
message?: AgentMessage & { idempotencyKey?: unknown };
|
||||
type?: unknown;
|
||||
};
|
||||
if (parsed.type === "message") {
|
||||
messageCount += 1;
|
||||
}
|
||||
if (typeof parsed.message?.idempotencyKey === "string") {
|
||||
idempotencyKeys.add(parsed.message.idempotencyKey);
|
||||
if (parsed.message.role === "user") {
|
||||
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
|
||||
}
|
||||
if (typeof parsed.message?.idempotencyKey === "string") {
|
||||
idempotencyKeys.add(parsed.message.idempotencyKey);
|
||||
if (parsed.message.role === "user") {
|
||||
userMessagesByIdempotencyKey.set(parsed.message.idempotencyKey, parsed.message);
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
return { idempotencyKeys, messageCount, userMessagesByIdempotencyKey };
|
||||
|
||||
@@ -3,7 +3,12 @@
|
||||
* turns replies into app-server answer payloads.
|
||||
*/
|
||||
import {
|
||||
buildAgentHarnessUserInputAnswers,
|
||||
deliverAgentHarnessUserInputPrompt,
|
||||
embeddedAgentLog,
|
||||
emptyAgentHarnessUserInputAnswers,
|
||||
type AgentHarnessUserInputOption,
|
||||
type AgentHarnessUserInputQuestion,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { formatCodexDisplayText } from "../command-formatters.js";
|
||||
@@ -19,25 +24,11 @@ type PendingUserInput = {
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
questions: UserInputQuestion[];
|
||||
questions: AgentHarnessUserInputQuestion[];
|
||||
resolve: (value: JsonValue) => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type UserInputQuestion = {
|
||||
id: string;
|
||||
header: string;
|
||||
question: string;
|
||||
isOther: boolean;
|
||||
isSecret: boolean;
|
||||
options: UserInputOption[] | null;
|
||||
};
|
||||
|
||||
type UserInputOption = {
|
||||
label: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type CodexUserInputBridge = {
|
||||
handleRequest: (request: {
|
||||
id: number | string;
|
||||
@@ -142,7 +133,7 @@ function readUserInputParams(value: JsonValue | undefined):
|
||||
threadId: string;
|
||||
turnId: string;
|
||||
itemId: string;
|
||||
questions: UserInputQuestion[];
|
||||
questions: AgentHarnessUserInputQuestion[];
|
||||
}
|
||||
| undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
@@ -157,11 +148,11 @@ function readUserInputParams(value: JsonValue | undefined):
|
||||
}
|
||||
const questions = questionsRaw
|
||||
.map(readQuestion)
|
||||
.filter((question): question is UserInputQuestion => Boolean(question));
|
||||
.filter((question): question is AgentHarnessUserInputQuestion => Boolean(question));
|
||||
return { threadId, turnId, itemId, questions };
|
||||
}
|
||||
|
||||
function readQuestion(value: JsonValue): UserInputQuestion | undefined {
|
||||
function readQuestion(value: JsonValue): AgentHarnessUserInputQuestion | undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -181,17 +172,17 @@ function readQuestion(value: JsonValue): UserInputQuestion | undefined {
|
||||
};
|
||||
}
|
||||
|
||||
function readOptions(value: JsonValue | undefined): UserInputOption[] | null {
|
||||
function readOptions(value: JsonValue | undefined): AgentHarnessUserInputOption[] | null {
|
||||
if (!Array.isArray(value)) {
|
||||
return null;
|
||||
}
|
||||
const options = value
|
||||
.map(readOption)
|
||||
.filter((option): option is UserInputOption => Boolean(option));
|
||||
.filter((option): option is AgentHarnessUserInputOption => Boolean(option));
|
||||
return options.length > 0 ? options : null;
|
||||
}
|
||||
|
||||
function readOption(value: JsonValue): UserInputOption | undefined {
|
||||
function readOption(value: JsonValue): AgentHarnessUserInputOption | undefined {
|
||||
if (!isJsonObject(value)) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -202,116 +193,25 @@ function readOption(value: JsonValue): UserInputOption | undefined {
|
||||
|
||||
async function deliverUserInputPrompt(
|
||||
params: EmbeddedRunAttemptParams,
|
||||
questions: UserInputQuestion[],
|
||||
questions: AgentHarnessUserInputQuestion[],
|
||||
): Promise<void> {
|
||||
const text = formatUserInputPrompt(questions);
|
||||
if (params.onBlockReply) {
|
||||
await params.onBlockReply({ text });
|
||||
return;
|
||||
}
|
||||
await params.onPartialReply?.({ text });
|
||||
}
|
||||
|
||||
function formatUserInputPrompt(questions: UserInputQuestion[]): string {
|
||||
const lines = ["Codex needs input:"];
|
||||
questions.forEach((question, index) => {
|
||||
if (questions.length > 1) {
|
||||
lines.push(
|
||||
"",
|
||||
`${index + 1}. ${formatCodexDisplayText(question.header)}`,
|
||||
formatCodexDisplayText(question.question),
|
||||
);
|
||||
} else {
|
||||
lines.push(
|
||||
"",
|
||||
formatCodexDisplayText(question.header),
|
||||
formatCodexDisplayText(question.question),
|
||||
);
|
||||
}
|
||||
if (question.isSecret) {
|
||||
lines.push("This channel may show your reply to other participants.");
|
||||
}
|
||||
question.options?.forEach((option, optionIndex) => {
|
||||
lines.push(
|
||||
`${optionIndex + 1}. ${formatCodexDisplayText(option.label)}${
|
||||
option.description ? ` - ${formatCodexDisplayText(option.description)}` : ""
|
||||
}`,
|
||||
);
|
||||
});
|
||||
if (question.isOther) {
|
||||
lines.push("Other: reply with your own answer.");
|
||||
}
|
||||
await deliverAgentHarnessUserInputPrompt(params, questions, {
|
||||
formatText: formatCodexDisplayText,
|
||||
intro: "Codex needs input:",
|
||||
});
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildUserInputResponse(questions: UserInputQuestion[], inputText: string): JsonObject {
|
||||
function buildUserInputResponse(
|
||||
questions: AgentHarnessUserInputQuestion[],
|
||||
inputText: string,
|
||||
): JsonObject {
|
||||
// Multi-question replies may use "header: answer" or numbered lines. Keep the
|
||||
// parser permissive so chat-channel replies remain ergonomic.
|
||||
const answers: JsonObject = {};
|
||||
if (questions.length === 1) {
|
||||
const question = questions[0];
|
||||
if (question) {
|
||||
const answer = normalizeAnswer(inputText, question);
|
||||
answers[question.id] = { answers: answer ? [answer] : [] };
|
||||
}
|
||||
return { answers };
|
||||
}
|
||||
|
||||
const keyed = parseKeyedAnswers(inputText);
|
||||
const fallbackLines = inputText
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
questions.forEach((question, index) => {
|
||||
const key =
|
||||
keyed.get(question.id.toLowerCase()) ??
|
||||
keyed.get(question.header.toLowerCase()) ??
|
||||
keyed.get(question.question.toLowerCase()) ??
|
||||
keyed.get(String(index + 1));
|
||||
const answer = key ?? fallbackLines[index] ?? "";
|
||||
const normalized = answer ? normalizeAnswer(answer, question) : undefined;
|
||||
answers[question.id] = { answers: normalized ? [normalized] : [] };
|
||||
});
|
||||
return { answers };
|
||||
}
|
||||
|
||||
function normalizeAnswer(answer: string, question: UserInputQuestion): string | undefined {
|
||||
const trimmed = answer.trim();
|
||||
const options = question.options ?? [];
|
||||
const optionIndex = /^\d+$/.test(trimmed) ? Number(trimmed) - 1 : -1;
|
||||
const indexed = optionIndex >= 0 ? options[optionIndex] : undefined;
|
||||
if (indexed) {
|
||||
return indexed.label;
|
||||
}
|
||||
const exact = options.find((option) => option.label.toLowerCase() === trimmed.toLowerCase());
|
||||
if (exact) {
|
||||
return exact.label;
|
||||
}
|
||||
if (options.length > 0 && !question.isOther) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed || undefined;
|
||||
}
|
||||
|
||||
function parseKeyedAnswers(inputText: string): Map<string, string> {
|
||||
const answers = new Map<string, string>();
|
||||
for (const line of inputText.split(/\r?\n/)) {
|
||||
const match = line.match(/^\s*([^:=-]+?)\s*[:=-]\s*(.+?)\s*$/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
const key = match[1]?.trim().toLowerCase();
|
||||
const value = match[2]?.trim();
|
||||
if (key && value) {
|
||||
answers.set(key, value);
|
||||
}
|
||||
}
|
||||
return answers;
|
||||
return buildAgentHarnessUserInputAnswers(questions, inputText) as unknown as JsonObject;
|
||||
}
|
||||
|
||||
function emptyUserInputResponse(): JsonObject {
|
||||
return { answers: {} };
|
||||
return emptyAgentHarnessUserInputAnswers() as unknown as JsonObject;
|
||||
}
|
||||
|
||||
function readString(record: JsonObject, key: string): string | undefined {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "copilot-proxy",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
@@ -1609,6 +1609,12 @@ describe("createCopilotAgentHarness", () => {
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
messagesRemoved: 4,
|
||||
summaryContent: "compacted summary",
|
||||
contextWindow: {
|
||||
tokenLimit: 1000,
|
||||
currentTokens: 777,
|
||||
messagesLength: 12,
|
||||
},
|
||||
}));
|
||||
const disconnect = vi.fn(async () => {
|
||||
throw new Error("disconnect failed");
|
||||
@@ -1649,6 +1655,7 @@ describe("createCopilotAgentHarness", () => {
|
||||
model: "gpt-4.1",
|
||||
sessionKey: "agent:main:main",
|
||||
sessionId: "oc-sess-compact-1",
|
||||
currentTokenCount: 900,
|
||||
workspaceDir: "/this\u0000is/illegal",
|
||||
customInstructions: "Keep decisions.",
|
||||
});
|
||||
@@ -1684,6 +1691,25 @@ describe("createCopilotAgentHarness", () => {
|
||||
ok: true,
|
||||
compacted: true,
|
||||
reason: "copilot-sdk-history-compacted",
|
||||
result: {
|
||||
summary: "compacted summary",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore: 900,
|
||||
tokensAfter: 777,
|
||||
details: {
|
||||
success: true,
|
||||
tokensRemoved: 123,
|
||||
messagesRemoved: 4,
|
||||
summaryContent: "compacted summary",
|
||||
contextWindow: {
|
||||
tokenLimit: 1000,
|
||||
currentTokens: 777,
|
||||
messagesLength: 12,
|
||||
},
|
||||
},
|
||||
sessionId: "oc-sess-compact-1",
|
||||
sessionFile: "/session.json",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -62,6 +62,14 @@ interface CopilotHistoryCompactResult {
|
||||
tokensRemoved: number;
|
||||
messagesRemoved: number;
|
||||
summaryContent?: string;
|
||||
contextWindow?: {
|
||||
tokenLimit: number;
|
||||
currentTokens: number;
|
||||
messagesLength: number;
|
||||
systemTokens?: number;
|
||||
conversationTokens?: number;
|
||||
toolDefinitionsTokens?: number;
|
||||
};
|
||||
}
|
||||
|
||||
interface CopilotHistoryCompactSession {
|
||||
@@ -872,6 +880,21 @@ export function createCopilotAgentHarness(
|
||||
ok: true,
|
||||
compacted,
|
||||
reason: compacted ? "copilot-sdk-history-compacted" : "already under target",
|
||||
...(compacted
|
||||
? {
|
||||
result: {
|
||||
summary: compactResult.summaryContent ?? "",
|
||||
firstKeptEntryId: "",
|
||||
tokensBefore:
|
||||
params.currentTokenCount ??
|
||||
(compactResult.contextWindow?.currentTokens ?? 0) + compactResult.tokensRemoved,
|
||||
tokensAfter: compactResult.contextWindow?.currentTokens,
|
||||
details: compactResult,
|
||||
sessionId: params.sessionId,
|
||||
sessionFile: params.sessionFile,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
},
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"id": "copilot",
|
||||
"name": "GitHub Copilot agent runtime",
|
||||
"description": "Registers the GitHub Copilot agent runtime.",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot/111111",
|
||||
"icon": "https://cdn.simpleicons.org/githubcopilot",
|
||||
"version": "2026.6.2",
|
||||
"activation": {
|
||||
"onStartup": false,
|
||||
|
||||
@@ -3,9 +3,11 @@ import fsp from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import type { CopilotClient, Tool as SdkTool } from "@github/copilot-sdk";
|
||||
import type {
|
||||
AgentHarnessAttemptParams,
|
||||
AgentHarnessAttemptResult,
|
||||
import {
|
||||
abortAgentHarnessRun,
|
||||
queueAgentHarnessMessage,
|
||||
type AgentHarnessAttemptParams,
|
||||
type AgentHarnessAttemptResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import type { SandboxContext } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
@@ -1171,6 +1173,36 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.externalAbort).toBe(true);
|
||||
});
|
||||
|
||||
it("active-run abort path marks the attempt as externally aborted", async () => {
|
||||
const sendDeferred = createDeferred<SessionEventShape | undefined>();
|
||||
const sessionCreated = createDeferred<FakeSession>();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session) => {
|
||||
session.sendAndWait.mockReturnValue(sendDeferred.promise);
|
||||
session.abort.mockImplementationOnce(async () => {
|
||||
sendDeferred.resolve(undefined);
|
||||
});
|
||||
sessionCreated.resolve(session);
|
||||
},
|
||||
});
|
||||
const pool = makeFakePool(sdk);
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
|
||||
const runPromise = runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
pool,
|
||||
});
|
||||
const session = await sessionCreated.promise;
|
||||
await vi.waitFor(() => expect(session.sendAndWait).toHaveBeenCalledTimes(1));
|
||||
|
||||
expect(abortAgentHarnessRun("session-1")).toBe(true);
|
||||
const result = await runPromise;
|
||||
|
||||
expect(session.abort).toHaveBeenCalledTimes(1);
|
||||
expect(result.aborted).toBe(true);
|
||||
expect(result.externalAbort).toBe(true);
|
||||
});
|
||||
|
||||
it("abort path (signal already aborted)", async () => {
|
||||
const controller = new AbortController();
|
||||
controller.abort();
|
||||
@@ -1447,18 +1479,42 @@ describe("runCopilotAttempt", () => {
|
||||
expect(result.feedback).toContain("no permission policy installed");
|
||||
});
|
||||
|
||||
it("does not register onUserInputRequest (ask_user hidden from the model in MVP)", async () => {
|
||||
const sdk = makeFakeSdk();
|
||||
it("registers ask_user and resolves it from the active OpenClaw queue", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const sdk = makeFakeSdk({
|
||||
onCreateSession: (session, cfg) => {
|
||||
session.sendAndWait.mockImplementationOnce(async () => {
|
||||
const handler = cfg.onUserInputRequest;
|
||||
if (typeof handler !== "function") {
|
||||
throw new Error("expected onUserInputRequest handler");
|
||||
}
|
||||
const response = await handler(
|
||||
{
|
||||
question: "Pick a mode",
|
||||
choices: ["Fast", "Deep"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: session.sessionId },
|
||||
);
|
||||
return makeAssistantMessageEvent(`selected ${response.answer}`);
|
||||
});
|
||||
},
|
||||
});
|
||||
const pool = makeFakePool(sdk);
|
||||
|
||||
await runCopilotAttempt(makeParams(), { pool });
|
||||
const attempt = runCopilotAttempt(makeParams({ onBlockReply }), { pool });
|
||||
|
||||
await vi.waitFor(() => expect(onBlockReply).toHaveBeenCalledTimes(1));
|
||||
expect(queueAgentHarnessMessage("session-1", "2")).toBe(true);
|
||||
const result = await attempt;
|
||||
|
||||
const cfg = sdk.createSession.mock.calls[0]?.[0];
|
||||
// Per the SDK contract (types.d.ts: `When provided, enables the
|
||||
// ask_user tool allowing the agent to ask questions`), omitting the
|
||||
// handler hides ask_user from the model entirely. The MVP keeps it
|
||||
// hidden until a real channel/TUI prompt bridge exists.
|
||||
expect("onUserInputRequest" in cfg).toBe(false);
|
||||
expect(typeof cfg.onUserInputRequest).toBe("function");
|
||||
expect(onBlockReply.mock.calls[0]?.[0]).toEqual(
|
||||
expect.objectContaining({ text: expect.stringContaining("Pick a mode") }),
|
||||
);
|
||||
expect(result.assistantTexts).toEqual(["selected Deep"]);
|
||||
expect(queueAgentHarnessMessage("session-1", "late")).toBe(false);
|
||||
});
|
||||
|
||||
it("enableSessionTelemetry is omitted from createSession when undefined (SDK default)", async () => {
|
||||
@@ -1854,6 +1910,7 @@ describe("runCopilotAttempt", () => {
|
||||
it("retains a timed-out session until later compaction reaches session.idle", async () => {
|
||||
const afterCompaction = vi.fn();
|
||||
const onDeferredCompaction = vi.fn();
|
||||
const cleanupToolBridge = vi.fn();
|
||||
initializeGlobalHookRunner(
|
||||
createMockPluginRegistry([{ hookName: "after_compaction", handler: afterCompaction }]),
|
||||
);
|
||||
@@ -1866,8 +1923,14 @@ describe("runCopilotAttempt", () => {
|
||||
);
|
||||
},
|
||||
});
|
||||
const createToolBridge = vi.fn(async () => ({
|
||||
cleanup: cleanupToolBridge,
|
||||
sdkTools: [],
|
||||
sourceTools: [],
|
||||
}));
|
||||
|
||||
const result = await runCopilotAttempt(makeParams(), {
|
||||
createToolBridge,
|
||||
onDeferredCompaction,
|
||||
pool: makeFakePool(sdk),
|
||||
});
|
||||
@@ -1877,6 +1940,7 @@ describe("runCopilotAttempt", () => {
|
||||
expect(onDeferredCompaction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ sdkSessionId: "sess-1" }),
|
||||
);
|
||||
expect(cleanupToolBridge).not.toHaveBeenCalled();
|
||||
expect(activeSession?.disconnect).not.toHaveBeenCalled();
|
||||
|
||||
activeSession?.emit("session.compaction_start", {});
|
||||
@@ -1890,6 +1954,7 @@ describe("runCopilotAttempt", () => {
|
||||
await vi.waitFor(() => {
|
||||
expect(activeSession?.disconnect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(cleanupToolBridge).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not mark a timeout after SDK compaction has completed as active compaction", async () => {
|
||||
@@ -2396,11 +2461,13 @@ describe("runCopilotAttempt", () => {
|
||||
expect(dualWriteMock.dualWriteCopilotTranscriptBestEffort).toHaveBeenCalledTimes(1);
|
||||
const args = dualWriteMock.dualWriteCopilotTranscriptBestEffort.mock.calls[0]?.[0] as {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
messages: Array<{ role: string }>;
|
||||
idempotencyScope?: string;
|
||||
};
|
||||
expect(args.sessionFile).toBe("session.json");
|
||||
expect(args.idempotencyScope).toMatch(/^copilot:/u);
|
||||
expect(args.sessionId).toBe("session-1");
|
||||
expect(args.idempotencyScope).toBe("copilot:sess-1");
|
||||
expect(args.messages.length).toBeGreaterThan(0);
|
||||
const roles = args.messages.map((m) => m.role);
|
||||
expect(roles).toContain("user");
|
||||
@@ -2447,10 +2514,9 @@ describe("runCopilotAttempt", () => {
|
||||
}
|
||||
const identity = message["__openclaw"]?.mirrorIdentity ?? "";
|
||||
// The terminal assistant carries the turn-stable
|
||||
// `${runId}:assistant:final` identity attached by attempt.ts
|
||||
// (rubber-duck-validated identity scheme — survives SDK session
|
||||
// reuse across turns). Caller-passed history without an
|
||||
// identity falls through to the positional `${scope}:role:idx`
|
||||
// `${runId}:assistant:final` identity attached by attempt.ts.
|
||||
// Caller-passed history without an identity falls through to
|
||||
// the positional `${scope}:role:idx`.
|
||||
// fingerprint that the existing tagging map applies.
|
||||
if (message.role === "assistant" && index === args.messages.length - 1) {
|
||||
expect(identity).toMatch(/:assistant:final$/u);
|
||||
@@ -3066,7 +3132,8 @@ describe("runCopilotAttempt", () => {
|
||||
// permission policy and pollute the catalog under the default reject
|
||||
// policy. `createSessionConfig` derives `availableTools` from the
|
||||
// post-filter `sdkTools` so create- and resume-session always carry
|
||||
// exactly the names of the tools the bridge actually exposed.
|
||||
// exactly the names of the tools the bridge actually exposed plus the
|
||||
// built-in `ask_user` tool owned by the registered user-input handler.
|
||||
describe("availableTools surface restriction (PR #86155 [P1] round-8)", () => {
|
||||
function makeFakeSdkTool(name: string): SdkTool {
|
||||
return {
|
||||
@@ -3090,7 +3157,11 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
|
||||
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["read", "edit"]);
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([
|
||||
"read",
|
||||
"edit",
|
||||
"builtin:ask_user",
|
||||
]);
|
||||
});
|
||||
|
||||
it("forwards `[]` to the SDK when the bridge returns no tools (disable / raw / fully filtered)", async () => {
|
||||
@@ -3100,12 +3171,13 @@ describe("runCopilotAttempt", () => {
|
||||
// (`modelRun: true` or `promptMode: "none"`), an empty
|
||||
// `toolsAllow: []`, and an unsupported provider to `sdkTools: []`.
|
||||
// Whatever the upstream reason, `availableTools` must be the same
|
||||
// empty list so the SDK cannot fall back to its native catalog.
|
||||
// ask_user-only list so the SDK cannot fall back to its native
|
||||
// catalog while the registered user-input handler remains usable.
|
||||
const createToolBridge = vi.fn(async () => ({ sdkTools: [], sourceTools: [] }));
|
||||
|
||||
await runCopilotAttempt(makeParams(), { createToolBridge, pool });
|
||||
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual([]);
|
||||
expect(readAvailableTools(sdk.createSession.mock.calls[0])).toEqual(["builtin:ask_user"]);
|
||||
});
|
||||
|
||||
it("forwards the full bridged set when the run is unrestricted (no toolsAllow)", async () => {
|
||||
@@ -3131,6 +3203,7 @@ describe("runCopilotAttempt", () => {
|
||||
"edit",
|
||||
"exec",
|
||||
"message",
|
||||
"builtin:ask_user",
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -3156,7 +3229,7 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
|
||||
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
|
||||
expect(resumeCfg?.availableTools).toEqual(["read"]);
|
||||
expect(resumeCfg?.availableTools).toEqual(["read", "builtin:ask_user"]);
|
||||
});
|
||||
|
||||
it("forwards `[]` to resumeSession when the bridge returns no tools", async () => {
|
||||
@@ -3175,7 +3248,7 @@ describe("runCopilotAttempt", () => {
|
||||
|
||||
const resumeCall = sdk.resumeSession.mock.calls[0] as unknown[] | undefined;
|
||||
const resumeCfg = resumeCall?.[1] as { availableTools?: string[] };
|
||||
expect(resumeCfg?.availableTools).toEqual([]);
|
||||
expect(resumeCfg?.availableTools).toEqual(["builtin:ask_user"]);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ import {
|
||||
runAgentEndSideEffects,
|
||||
runAgentHarnessLlmInputHook,
|
||||
runAgentHarnessLlmOutputHook,
|
||||
clearActiveEmbeddedRun,
|
||||
setActiveEmbeddedRun,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { resolveCopilotAuth } from "./auth-bridge.js";
|
||||
import {
|
||||
@@ -42,6 +44,7 @@ import {
|
||||
type SessionLike,
|
||||
} from "./event-bridge.js";
|
||||
import { createHooksBridge, type CopilotHooksConfig } from "./hooks-bridge.js";
|
||||
import { createCopilotNativeSubagentTaskMirror } from "./native-subagent-task-mirror.js";
|
||||
import {
|
||||
createPermissionBridge,
|
||||
rejectAllPolicy,
|
||||
@@ -55,10 +58,12 @@ import {
|
||||
} from "./replay-shim.js";
|
||||
import type { ClientCreateOptions, CopilotClientPool, PoolKey, PooledClient } from "./runtime.js";
|
||||
import { createCopilotToolBridge } from "./tool-bridge.js";
|
||||
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
|
||||
import { resolveCopilotWorkspaceBootstrapContext } from "./workspace-bootstrap.js";
|
||||
|
||||
const SUPPORTED_PROVIDERS = new Set(["github-copilot"]);
|
||||
const BACKGROUND_COMPACTION_CANCEL_TIMEOUT_MS = 5_000;
|
||||
const COPILOT_ASK_USER_AVAILABLE_TOOLS = ["builtin:ask_user"] as const;
|
||||
|
||||
type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & { sdkSessionId?: string };
|
||||
type PromptErrorWithCode = Error & { code?: string; cause?: unknown };
|
||||
@@ -73,6 +78,7 @@ export type CopilotSessionConfig = Pick<
|
||||
| "infiniteSessions"
|
||||
| "model"
|
||||
| "onPermissionRequest"
|
||||
| "onUserInputRequest"
|
||||
| "reasoningEffort"
|
||||
| "systemMessage"
|
||||
| "tools"
|
||||
@@ -222,6 +228,8 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
bridge: ReturnType<typeof attachEventBridge>;
|
||||
handle: PooledClient;
|
||||
pool: CopilotClientPool;
|
||||
cleanupToolBridge?: () => void;
|
||||
finalizeNativeSubagents?: () => void;
|
||||
sdkSessionId?: string;
|
||||
session: SessionLike;
|
||||
timeoutMs: number;
|
||||
@@ -244,12 +252,14 @@ function deferBackgroundCompactionCleanup(params: {
|
||||
await cancelBackgroundCompactionBeforeTeardown(params.session);
|
||||
params.bridge.settleCompactionWait();
|
||||
}
|
||||
params.finalizeNativeSubagents?.();
|
||||
params.bridge.detach();
|
||||
try {
|
||||
await params.session.disconnect();
|
||||
} catch {
|
||||
// The attempt has already returned its timeout result.
|
||||
}
|
||||
params.cleanupToolBridge?.();
|
||||
if (outcome !== "completed" && params.sdkSessionId) {
|
||||
try {
|
||||
await params.handle.client.deleteSession(params.sdkSessionId);
|
||||
@@ -403,6 +413,14 @@ export async function runCopilotAttempt(
|
||||
let handle: PooledClient | undefined;
|
||||
let session: SessionLike | undefined;
|
||||
let bridge: ReturnType<typeof attachEventBridge> | undefined;
|
||||
const nativeSubagentTaskMirror = createCopilotNativeSubagentTaskMirror({
|
||||
agentId: sessionAgentId,
|
||||
now,
|
||||
scope: input.agentHarnessTaskRuntimeScope,
|
||||
});
|
||||
let activeRunHandleRef: Parameters<typeof clearActiveEmbeddedRun>[1] | undefined;
|
||||
let userInputBridgeRef: ReturnType<typeof createCopilotUserInputBridge> | undefined;
|
||||
let cleanupToolBridge: (() => void) | undefined;
|
||||
let releaseError: Error | undefined;
|
||||
let downgradedFromResume = false;
|
||||
let resumeFailureRecovered = false;
|
||||
@@ -415,16 +433,24 @@ export async function runCopilotAttempt(
|
||||
// `src/agents/pi-embedded-runner/run/types.ts:139`.
|
||||
let yieldDetected = false;
|
||||
|
||||
const onAbort = () => {
|
||||
const markExternalAbort = () => {
|
||||
abortRequested = true;
|
||||
externalAbort = true;
|
||||
aborted = true;
|
||||
};
|
||||
|
||||
const abortActiveSession = () => {
|
||||
markExternalAbort();
|
||||
if (settled || !sentTurnStarted || !session) {
|
||||
return;
|
||||
}
|
||||
void session.abort().catch(() => undefined);
|
||||
};
|
||||
|
||||
const onAbort = () => {
|
||||
abortActiveSession();
|
||||
};
|
||||
|
||||
params.abortSignal?.addEventListener("abort", onAbort, { once: true });
|
||||
|
||||
// Sandbox parity with PI (`src/agents/pi-embedded-runner/run/attempt.ts:1232-1244`):
|
||||
@@ -575,6 +601,7 @@ export async function runCopilotAttempt(
|
||||
startedAt,
|
||||
}),
|
||||
});
|
||||
cleanupToolBridge = toolBridge.cleanup;
|
||||
sdkTools = toolBridge.sdkTools;
|
||||
} catch (error: unknown) {
|
||||
const result = createResult(input, {
|
||||
@@ -655,6 +682,11 @@ export async function runCopilotAttempt(
|
||||
});
|
||||
};
|
||||
const hasNativePromptHook = Boolean(attemptInput.hooksConfig?.onUserPromptSubmitted);
|
||||
const userInputBridge = createCopilotUserInputBridge({
|
||||
paramsForRun: attemptInput,
|
||||
signal: params.abortSignal,
|
||||
});
|
||||
userInputBridgeRef = userInputBridge;
|
||||
const sessionConfig = createSessionConfig(
|
||||
attemptInput,
|
||||
modelRef.id,
|
||||
@@ -663,6 +695,7 @@ export async function runCopilotAttempt(
|
||||
promptBuild.developerInstructions || undefined,
|
||||
effectiveWorkspaceDir,
|
||||
effectiveCwd,
|
||||
userInputBridge.onUserInputRequest,
|
||||
hasNativePromptHook
|
||||
? {
|
||||
onUserPromptSubmitted: ({ additionalContext, prompt }) =>
|
||||
@@ -723,6 +756,8 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
bridge = attachEventBridge(session, {
|
||||
onAssistantDelta: input.onAssistantDelta,
|
||||
onAgentEvent: input.onAgentEvent,
|
||||
onNativeSubagentEvent: (event) => nativeSubagentTaskMirror?.handleEvent(event),
|
||||
onCompactionStart: async () => {
|
||||
const sessionFile = readString(input.sessionFile);
|
||||
if (!sessionFile) {
|
||||
@@ -748,6 +783,29 @@ export async function runCopilotAttempt(
|
||||
isAborted: () => aborted,
|
||||
});
|
||||
|
||||
const activeRunHandle = {
|
||||
kind: "embedded" as const,
|
||||
queueMessage: async (text: string) => {
|
||||
if (userInputBridge.handleQueuedMessage(text)) {
|
||||
return;
|
||||
}
|
||||
throw new Error("Copilot runtime is not waiting for user input.");
|
||||
},
|
||||
isStreaming: () => !settled && !aborted,
|
||||
isCompacting: () => bridge?.isCompacting() ?? false,
|
||||
sourceReplyDeliveryMode: input.sourceReplyDeliveryMode,
|
||||
cancel: () => {
|
||||
userInputBridge.cancelPending();
|
||||
abortActiveSession();
|
||||
},
|
||||
abort: () => {
|
||||
userInputBridge.cancelPending();
|
||||
abortActiveSession();
|
||||
},
|
||||
};
|
||||
setActiveEmbeddedRun(input.sessionId, activeRunHandle, input.sessionKey, input.sessionFile);
|
||||
activeRunHandleRef = activeRunHandle;
|
||||
|
||||
const messageOptions = await createMessageOptions(attemptInput, {
|
||||
effectiveCwd,
|
||||
effectiveWorkspaceDir,
|
||||
@@ -765,6 +823,7 @@ export async function runCopilotAttempt(
|
||||
}
|
||||
const result = await session.sendAndWait(messageOptions, input.timeoutMs);
|
||||
await bridge.awaitDeltaChain();
|
||||
await bridge.awaitAgentEventChain();
|
||||
if (!bridge.recordSendResult(result) && !aborted) {
|
||||
// SDK sendAndWait returning undefined is treated as a timeout by the
|
||||
// capability inventory. Do not call session.abort() here: OpenClaw may
|
||||
@@ -800,12 +859,22 @@ export async function runCopilotAttempt(
|
||||
} catch {
|
||||
// delta-flush failure must not mask the timeout state
|
||||
}
|
||||
await bridge?.awaitAgentEventChain();
|
||||
} else {
|
||||
promptError = toError(error);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
settled = true;
|
||||
userInputBridgeRef?.cancelPending();
|
||||
if (activeRunHandleRef) {
|
||||
clearActiveEmbeddedRun(
|
||||
input.sessionId,
|
||||
activeRunHandleRef,
|
||||
input.sessionKey,
|
||||
input.sessionFile,
|
||||
);
|
||||
}
|
||||
const retainSessionForDeferredCleanup =
|
||||
bridge?.hasObservedCompaction() || (timedOut && bridge?.hasObservedSessionIdle() === false);
|
||||
if (retainSessionForDeferredCleanup && bridge && session && handle) {
|
||||
@@ -820,6 +889,8 @@ export async function runCopilotAttempt(
|
||||
abortSignal: cleanupAbort.signal,
|
||||
awaitSessionIdle: !bridge.hasObservedSessionIdle(),
|
||||
bridge,
|
||||
cleanupToolBridge,
|
||||
finalizeNativeSubagents: () => nativeSubagentTaskMirror?.finalizeActiveRuns(),
|
||||
handle,
|
||||
pool: deps.pool,
|
||||
sdkSessionId,
|
||||
@@ -848,6 +919,9 @@ export async function runCopilotAttempt(
|
||||
// defines as no background agents in flight. Timeouts retain the bridge
|
||||
// until that event so compaction that starts after the timer still completes.
|
||||
await bridge?.awaitCompactionChain();
|
||||
await bridge?.awaitAgentEventChain();
|
||||
nativeSubagentTaskMirror?.finalizeActiveRuns();
|
||||
cleanupToolBridge?.();
|
||||
bridge?.detach();
|
||||
params.abortSignal?.removeEventListener("abort", onAbort);
|
||||
|
||||
@@ -931,8 +1005,9 @@ export async function runCopilotAttempt(
|
||||
// extension. Identity-tagged so re-emits dedupe. Errors are
|
||||
// swallowed so a mirror failure cannot break the attempt.
|
||||
const sessionFileForMirror = readString(input.sessionFile);
|
||||
const sessionIdForScope = sessionIdUsed ?? readString(input.sessionId);
|
||||
if (sessionFileForMirror && messagesSnapshot.length > 0) {
|
||||
const openClawSessionIdForMirror = readString(input.sessionId);
|
||||
const mirrorScopeSessionId = sessionIdUsed ?? openClawSessionIdForMirror;
|
||||
if (sessionFileForMirror && openClawSessionIdForMirror && messagesSnapshot.length > 0) {
|
||||
const taggedMessages = messagesSnapshot.map((message, index) => {
|
||||
if (
|
||||
message.role !== "user" &&
|
||||
@@ -953,15 +1028,16 @@ export async function runCopilotAttempt(
|
||||
if (hasMirrorIdentity(message)) {
|
||||
return message;
|
||||
}
|
||||
const identityScope = sdkSessionId ?? sessionIdForScope ?? "attempt";
|
||||
const identityScope = sdkSessionId ?? mirrorScopeSessionId ?? "attempt";
|
||||
return attachCopilotMirrorIdentity(message, `${identityScope}:${message.role}:${index}`);
|
||||
});
|
||||
await dualWriteCopilotTranscriptBestEffort({
|
||||
sessionFile: sessionFileForMirror,
|
||||
sessionId: openClawSessionIdForMirror,
|
||||
sessionKey: readString((input as { sessionKey?: unknown }).sessionKey),
|
||||
agentId: readString(input.agentId),
|
||||
messages: taggedMessages,
|
||||
idempotencyScope: sessionIdForScope ? `copilot:${sessionIdForScope}` : undefined,
|
||||
idempotencyScope: mirrorScopeSessionId ? `copilot:${mirrorScopeSessionId}` : undefined,
|
||||
config: (input as { config?: unknown }).config as never,
|
||||
}).catch((mirrorError: unknown) => {
|
||||
// Defense-in-depth: the best-effort wrapper already swallows
|
||||
@@ -1118,6 +1194,7 @@ function createSessionConfig(
|
||||
systemMessageContent: string | undefined,
|
||||
effectiveWorkspaceDir: string | undefined,
|
||||
effectiveCwd: string | undefined,
|
||||
onUserInputRequest: NonNullable<SessionConfig["onUserInputRequest"]>,
|
||||
hooksBridgeOptions?: Parameters<typeof createHooksBridge>[1],
|
||||
): CopilotSessionConfig {
|
||||
const permissionPolicy = params.permissionPolicy ?? rejectAllPolicy;
|
||||
@@ -1145,10 +1222,9 @@ function createSessionConfig(
|
||||
// tool wrapper, and the SDK gate is a safety net for kinds we
|
||||
// don't surface. See permission-bridge.ts and docs/plugins/copilot.md.
|
||||
onPermissionRequest: createPermissionBridge(permissionPolicy),
|
||||
// `onUserInputRequest` is intentionally NOT registered: per the SDK
|
||||
// contract, omitting the handler hides the `ask_user` tool from the
|
||||
// model entirely. Interactive ask_user will need a real channel/TUI
|
||||
// prompt bridge before this runtime can expose the handler.
|
||||
// Registers the SDK ask_user bridge. The bridge itself owns pending
|
||||
// reply routing so generic mid-run steering still fails closed.
|
||||
onUserInputRequest,
|
||||
// Preserve the shipped native SDK hook contract. These callbacks expose
|
||||
// Copilot-specific events and decisions that generic lifecycle hooks do
|
||||
// not model.
|
||||
@@ -1166,8 +1242,9 @@ function createSessionConfig(
|
||||
...(infiniteSessions ? { infiniteSessions } : {}),
|
||||
reasoningEffort: params.reasoningEffort,
|
||||
tools: sdkTools,
|
||||
// Restrict the SDK's tool catalog to exactly the bridged tool names
|
||||
// returned by `createCopilotToolBridge`. Without this, the SDK
|
||||
// Restrict the SDK's tool catalog to the bridged tool names returned
|
||||
// by `createCopilotToolBridge` plus the built-in `ask_user` tool owned
|
||||
// by `onUserInputRequest`. Without this, the SDK
|
||||
// would still expose its native read/write/shell/url/mcp/memory/
|
||||
// hook tools to the model alongside our overrides, which would
|
||||
// bypass OpenClaw's wrapped-tool enforcement under any permissive
|
||||
@@ -1182,7 +1259,7 @@ function createSessionConfig(
|
||||
// `@github/copilot-sdk/dist/types.d.ts:1198` (it picks
|
||||
// `availableTools`, so the spread into `resumeSession` covers
|
||||
// the resume path too).
|
||||
availableTools: sdkTools.map((tool) => tool.name),
|
||||
availableTools: buildCopilotAvailableTools(sdkTools),
|
||||
workingDirectory:
|
||||
effectiveCwd ?? effectiveWorkspaceDir ?? readResolvedAttemptPath(params.workspaceDir),
|
||||
// When a task runs from a sub-cwd, keep SDK-native project docs
|
||||
@@ -1228,6 +1305,10 @@ function createSessionConfig(
|
||||
};
|
||||
}
|
||||
|
||||
function buildCopilotAvailableTools(sdkTools: SdkTool[]): string[] {
|
||||
return [...new Set([...sdkTools.map((tool) => tool.name), ...COPILOT_ASK_USER_AVAILABLE_TOOLS])];
|
||||
}
|
||||
|
||||
async function createMessageOptions(
|
||||
params: AttemptParamsLike,
|
||||
context: {
|
||||
|
||||
@@ -86,6 +86,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [userMessage, assistantMessage, toolResultMessage],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
@@ -113,6 +114,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
@@ -143,12 +145,14 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [...messages],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
});
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [...messages],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
@@ -185,6 +189,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [sourceMessage],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
@@ -210,6 +215,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
@@ -228,6 +234,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
sessionKey: "session-1",
|
||||
messages: [],
|
||||
idempotencyScope: "copilot:session-1",
|
||||
@@ -245,6 +252,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [message],
|
||||
idempotencyScope: "scope-fp",
|
||||
});
|
||||
@@ -263,6 +271,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [tagged],
|
||||
idempotencyScope: "copilot:openclaw-session-1",
|
||||
});
|
||||
@@ -279,6 +288,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "no scope" }],
|
||||
@@ -306,6 +316,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [userMessage, systemLike],
|
||||
idempotencyScope: "scope",
|
||||
});
|
||||
@@ -326,6 +337,7 @@ describe("mirrorCopilotTranscript", () => {
|
||||
|
||||
await mirrorCopilotTranscript({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [second],
|
||||
idempotencyScope: "scope",
|
||||
});
|
||||
@@ -342,6 +354,7 @@ describe("dualWriteCopilotTranscriptBestEffort", () => {
|
||||
await expect(
|
||||
dualWriteCopilotTranscriptBestEffort({
|
||||
sessionFile,
|
||||
sessionId: "session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
@@ -356,22 +369,34 @@ describe("dualWriteCopilotTranscriptBestEffort", () => {
|
||||
});
|
||||
|
||||
it("swallows infrastructure failures and never rejects", async () => {
|
||||
// Pointing sessionFile at a path under a non-existent root with an
|
||||
// empty-string segment can fail differently on different platforms;
|
||||
// instead force failure by passing an invalid type and asserting
|
||||
// that the wrapper itself does not reject. Use any-cast for the
|
||||
// bad input shape since we are testing the wrapper's catch.
|
||||
await expect(
|
||||
dualWriteCopilotTranscriptBestEffort({
|
||||
sessionFile: "" as unknown as string,
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "should-not-throw" }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
],
|
||||
idempotencyScope: "scope",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
const root = await makeRoot("openclaw-copilot-mirror-invalid-");
|
||||
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
|
||||
process.env.OPENCLAW_STATE_DIR = root;
|
||||
try {
|
||||
await expect(
|
||||
dualWriteCopilotTranscriptBestEffort({
|
||||
agentId: "main",
|
||||
sessionFile: "",
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
messages: [
|
||||
makeAgentAssistantMessage({
|
||||
content: [{ type: "text", text: "should-not-throw" }],
|
||||
timestamp: Date.now(),
|
||||
}),
|
||||
],
|
||||
idempotencyScope: "scope",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
await expect(
|
||||
fs.access(path.join(root, "agents", "main", "sessions", "session-1.jsonl")),
|
||||
).rejects.toHaveProperty("code", "ENOENT");
|
||||
} finally {
|
||||
if (previousStateDir === undefined) {
|
||||
delete process.env.OPENCLAW_STATE_DIR;
|
||||
} else {
|
||||
process.env.OPENCLAW_STATE_DIR = previousStateDir;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -29,16 +29,16 @@
|
||||
*/
|
||||
|
||||
import { createHash } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import {
|
||||
acquireSessionWriteLock,
|
||||
appendSessionTranscriptMessage,
|
||||
emitSessionTranscriptUpdate,
|
||||
resolveSessionWriteLockAcquireTimeoutMs,
|
||||
runAgentHarnessBeforeMessageWriteHook,
|
||||
type AgentMessage,
|
||||
type SessionWriteLockAcquireTimeoutConfig,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
publishSessionTranscriptUpdateByIdentity,
|
||||
withSessionTranscriptWriteLock,
|
||||
type SessionTranscriptTargetParams,
|
||||
type SessionTranscriptWriteLockParams,
|
||||
} from "openclaw/plugin-sdk/session-transcript-runtime";
|
||||
|
||||
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
|
||||
|
||||
@@ -95,6 +95,7 @@ function buildMirrorDedupeIdentity(message: MirroredAgentMessage): string {
|
||||
|
||||
export interface MirrorCopilotTranscriptParams {
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
agentId?: string;
|
||||
messages: AgentMessage[];
|
||||
@@ -106,7 +107,7 @@ export interface MirrorCopilotTranscriptParams {
|
||||
* entry collide with its existing on-disk key and be a true no-op.
|
||||
*/
|
||||
idempotencyScope?: string;
|
||||
config?: SessionWriteLockAcquireTimeoutConfig;
|
||||
config?: SessionTranscriptWriteLockParams["config"];
|
||||
}
|
||||
|
||||
export async function mirrorCopilotTranscript(
|
||||
@@ -120,82 +121,91 @@ export async function mirrorCopilotTranscript(
|
||||
return;
|
||||
}
|
||||
|
||||
const lock = await acquireSessionWriteLock({
|
||||
sessionFile: params.sessionFile,
|
||||
timeoutMs: resolveSessionWriteLockAcquireTimeoutMs(params.config),
|
||||
});
|
||||
try {
|
||||
const existingIdempotencyKeys = await readTranscriptIdempotencyKeys(params.sessionFile);
|
||||
for (const message of messages) {
|
||||
const dedupeIdentity = buildMirrorDedupeIdentity(message);
|
||||
const idempotencyKey = params.idempotencyScope
|
||||
? `${params.idempotencyScope}:${dedupeIdentity}`
|
||||
: undefined;
|
||||
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
|
||||
continue;
|
||||
const transcriptTarget = resolveCopilotMirrorTranscriptTarget(params);
|
||||
const didAppend = await withSessionTranscriptWriteLock(
|
||||
{ ...transcriptTarget, config: params.config },
|
||||
async (transcript) => {
|
||||
let didAppendMessage = false;
|
||||
const existingIdempotencyKeys = readTranscriptIdempotencyKeys(await transcript.readEvents());
|
||||
for (const message of messages) {
|
||||
const dedupeIdentity = buildMirrorDedupeIdentity(message);
|
||||
const idempotencyKey = params.idempotencyScope
|
||||
? `${params.idempotencyScope}:${dedupeIdentity}`
|
||||
: undefined;
|
||||
if (idempotencyKey && existingIdempotencyKeys.has(idempotencyKey)) {
|
||||
continue;
|
||||
}
|
||||
const transcriptMessage = {
|
||||
...message,
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
} as AgentMessage;
|
||||
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
|
||||
message: transcriptMessage,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!nextMessage) {
|
||||
continue;
|
||||
}
|
||||
const messageToAppend = (
|
||||
idempotencyKey
|
||||
? {
|
||||
...(nextMessage as unknown as Record<string, unknown>),
|
||||
idempotencyKey,
|
||||
}
|
||||
: nextMessage
|
||||
) as AgentMessage;
|
||||
const appended = await transcript.appendMessage({
|
||||
message: messageToAppend,
|
||||
idempotencyLookup: idempotencyKey ? "caller-checked" : "scan",
|
||||
});
|
||||
if (!appended) {
|
||||
continue;
|
||||
}
|
||||
didAppendMessage = true;
|
||||
if (idempotencyKey) {
|
||||
existingIdempotencyKeys.add(idempotencyKey);
|
||||
}
|
||||
}
|
||||
const transcriptMessage = {
|
||||
...message,
|
||||
...(idempotencyKey ? { idempotencyKey } : {}),
|
||||
} as AgentMessage;
|
||||
const nextMessage = runAgentHarnessBeforeMessageWriteHook({
|
||||
message: transcriptMessage,
|
||||
agentId: params.agentId,
|
||||
sessionKey: params.sessionKey,
|
||||
});
|
||||
if (!nextMessage) {
|
||||
continue;
|
||||
}
|
||||
const messageToAppend = (
|
||||
idempotencyKey
|
||||
? {
|
||||
...(nextMessage as unknown as Record<string, unknown>),
|
||||
idempotencyKey,
|
||||
}
|
||||
: nextMessage
|
||||
) as AgentMessage;
|
||||
await appendSessionTranscriptMessage({
|
||||
transcriptPath: params.sessionFile,
|
||||
message: messageToAppend,
|
||||
config: params.config,
|
||||
});
|
||||
if (idempotencyKey) {
|
||||
existingIdempotencyKeys.add(idempotencyKey);
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await lock.release();
|
||||
}
|
||||
return didAppendMessage;
|
||||
},
|
||||
);
|
||||
|
||||
if (params.sessionKey) {
|
||||
emitSessionTranscriptUpdate({ sessionFile: params.sessionFile, sessionKey: params.sessionKey });
|
||||
} else {
|
||||
emitSessionTranscriptUpdate(params.sessionFile);
|
||||
if (didAppend) {
|
||||
await publishSessionTranscriptUpdateByIdentity({
|
||||
...transcriptTarget,
|
||||
update: params.sessionKey ? { sessionKey: params.sessionKey } : undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function readTranscriptIdempotencyKeys(sessionFile: string): Promise<Set<string>> {
|
||||
const keys = new Set<string>();
|
||||
let raw: string;
|
||||
try {
|
||||
raw = await fs.readFile(sessionFile, "utf8");
|
||||
} catch (error) {
|
||||
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
||||
throw error;
|
||||
}
|
||||
return keys;
|
||||
function resolveCopilotMirrorTranscriptTarget(params: {
|
||||
agentId?: string;
|
||||
sessionFile: string;
|
||||
sessionId: string;
|
||||
sessionKey?: string;
|
||||
}): SessionTranscriptTargetParams {
|
||||
const sessionFile = params.sessionFile.trim();
|
||||
if (!sessionFile) {
|
||||
throw new Error("Copilot transcript mirror requires a sessionFile target");
|
||||
}
|
||||
for (const line of raw.split(/\r?\n/)) {
|
||||
if (!line.trim()) {
|
||||
return {
|
||||
...(params.agentId ? { agentId: params.agentId } : {}),
|
||||
sessionFile,
|
||||
sessionId: params.sessionId,
|
||||
sessionKey: params.sessionKey ?? "",
|
||||
};
|
||||
}
|
||||
|
||||
function readTranscriptIdempotencyKeys(events: unknown[]): Set<string> {
|
||||
const keys = new Set<string>();
|
||||
for (const event of events) {
|
||||
if (!event || typeof event !== "object" || Array.isArray(event)) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const parsed = JSON.parse(line) as { message?: { idempotencyKey?: unknown } };
|
||||
if (typeof parsed.message?.idempotencyKey === "string") {
|
||||
keys.add(parsed.message.idempotencyKey);
|
||||
}
|
||||
} catch {
|
||||
continue;
|
||||
const parsed = event as { message?: { idempotencyKey?: unknown } };
|
||||
if (typeof parsed.message?.idempotencyKey === "string") {
|
||||
keys.add(parsed.message.idempotencyKey);
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
|
||||
@@ -15,6 +15,11 @@ const REGISTERED_EVENT_TYPES = [
|
||||
"assistant.usage",
|
||||
"tool.execution_start",
|
||||
"tool.execution_complete",
|
||||
"session.plan_changed",
|
||||
"exit_plan_mode.requested",
|
||||
"subagent.started",
|
||||
"subagent.completed",
|
||||
"subagent.failed",
|
||||
"session.compaction_start",
|
||||
"session.compaction_complete",
|
||||
"session.idle",
|
||||
@@ -455,6 +460,78 @@ describe("attachEventBridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("projects Copilot plan events through the generic plan stream", async () => {
|
||||
const session = createFakeSession();
|
||||
const onAgentEvent = vi.fn().mockResolvedValue(undefined);
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onAgentEvent,
|
||||
});
|
||||
|
||||
session.emit(
|
||||
"session.plan_changed",
|
||||
makeEvent("session.plan_changed", { operation: "update" }),
|
||||
);
|
||||
session.emit(
|
||||
"exit_plan_mode.requested",
|
||||
makeEvent("exit_plan_mode.requested", {
|
||||
actions: ["approve", "edit"],
|
||||
planContent: "# Plan\n- inspect\n- patch",
|
||||
recommendedAction: "approve",
|
||||
requestId: "request-1",
|
||||
summary: "Plan ready",
|
||||
}),
|
||||
);
|
||||
|
||||
await bridge.awaitAgentEventChain();
|
||||
|
||||
expect(onAgentEvent).toHaveBeenCalledTimes(2);
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(1, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
operation: "update",
|
||||
},
|
||||
});
|
||||
expect(onAgentEvent).toHaveBeenNthCalledWith(2, {
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
explanation: "Plan ready",
|
||||
steps: ["# Plan", "inspect", "patch"],
|
||||
actions: ["approve", "edit"],
|
||||
requestId: "request-1",
|
||||
recommendedAction: "approve",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forwards native Copilot subagent lifecycle events to the adapter", () => {
|
||||
const session = createFakeSession();
|
||||
const onNativeSubagentEvent = vi.fn();
|
||||
const bridge = attachEventBridge(session, {
|
||||
getSdkSessionId: () => "sdk-session-id",
|
||||
isAborted: () => false,
|
||||
onNativeSubagentEvent,
|
||||
});
|
||||
const event = makeEvent("subagent.started", {
|
||||
agentDescription: "inspect the repository",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-1",
|
||||
});
|
||||
|
||||
session.emit("subagent.started", event);
|
||||
|
||||
expect(onNativeSubagentEvent).toHaveBeenCalledWith(event);
|
||||
bridge.detach();
|
||||
});
|
||||
|
||||
it("preserves all-zero usage snapshot after an invalid assistant.usage event", () => {
|
||||
const session = createFakeSession();
|
||||
const bridge = attachEventBridge(session, {
|
||||
|
||||
@@ -41,6 +41,16 @@ export interface SessionLike {
|
||||
|
||||
export interface EventBridgeOptions {
|
||||
onAssistantDelta?: (payload: OnAssistantDeltaPayload) => void | Promise<void>;
|
||||
onAgentEvent?: (event: {
|
||||
stream: "item" | "plan";
|
||||
data: Record<string, unknown>;
|
||||
}) => void | Promise<void>;
|
||||
onNativeSubagentEvent?: (
|
||||
event: Extract<
|
||||
SessionEvent,
|
||||
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
|
||||
>,
|
||||
) => void;
|
||||
onCompactionComplete?: (payload: {
|
||||
messagesRemoved?: number;
|
||||
success: boolean;
|
||||
@@ -72,6 +82,7 @@ export interface EventBridgeController {
|
||||
awaitSessionIdle(): Promise<void>;
|
||||
settleCompactionWait(): void;
|
||||
awaitDeltaChain(): Promise<void>;
|
||||
awaitAgentEventChain(): Promise<void>;
|
||||
hasObservedCompaction(): boolean;
|
||||
hasObservedSessionIdle(): boolean;
|
||||
isCompacting(): boolean;
|
||||
@@ -103,6 +114,7 @@ export function attachEventBridge(
|
||||
let observedCompaction = false;
|
||||
let deltaQueue = Promise.resolve();
|
||||
let deltaChain = Promise.resolve();
|
||||
let agentEventChain = Promise.resolve();
|
||||
let compactionChain = Promise.resolve();
|
||||
let compactionIdle = Promise.resolve();
|
||||
let resolveCompactionIdle: (() => void) | undefined;
|
||||
@@ -191,6 +203,51 @@ export function attachEventBridge(
|
||||
}
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.plan_changed", (event) => {
|
||||
enqueueAgentEvent({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
operation: event.data.operation,
|
||||
...(event.agentId ? { agentId: event.agentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "exit_plan_mode.requested", (event) => {
|
||||
const steps = splitPlanText(event.data.planContent);
|
||||
enqueueAgentEvent({
|
||||
stream: "plan",
|
||||
data: {
|
||||
phase: "update",
|
||||
title: "Plan updated",
|
||||
source: "copilot-sdk",
|
||||
...(event.data.summary ? { explanation: event.data.summary } : {}),
|
||||
...(steps.length > 0 ? { steps } : {}),
|
||||
...(event.data.actions.length > 0 ? { actions: event.data.actions } : {}),
|
||||
...(event.data.requestId ? { requestId: event.data.requestId } : {}),
|
||||
...(event.data.recommendedAction
|
||||
? { recommendedAction: event.data.recommendedAction }
|
||||
: {}),
|
||||
...(event.agentId ? { agentId: event.agentId } : {}),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.started", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.completed", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "subagent.failed", (event) => {
|
||||
forwardNativeSubagentEvent(event);
|
||||
});
|
||||
|
||||
registerListener(session, unsubscribeFns, "session.compaction_start", (event) => {
|
||||
if (!isRootCompactionEvent(event)) {
|
||||
return;
|
||||
@@ -276,6 +333,9 @@ export function attachEventBridge(
|
||||
awaitDeltaChain() {
|
||||
return deltaChain;
|
||||
},
|
||||
awaitAgentEventChain() {
|
||||
return agentEventChain;
|
||||
},
|
||||
hasObservedCompaction() {
|
||||
return observedCompaction;
|
||||
},
|
||||
@@ -334,6 +394,31 @@ export function attachEventBridge(
|
||||
compactionChain = queued.catch(() => undefined);
|
||||
}
|
||||
|
||||
function enqueueAgentEvent(event: {
|
||||
stream: "item" | "plan";
|
||||
data: Record<string, unknown>;
|
||||
}): void {
|
||||
const callback = options.onAgentEvent;
|
||||
if (!callback) {
|
||||
return;
|
||||
}
|
||||
const invoke = () => callback(event);
|
||||
agentEventChain = agentEventChain.then(invoke, invoke).catch(() => undefined);
|
||||
}
|
||||
|
||||
function forwardNativeSubagentEvent(
|
||||
event: Extract<
|
||||
SessionEvent,
|
||||
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
|
||||
>,
|
||||
): void {
|
||||
try {
|
||||
options.onNativeSubagentEvent?.(event);
|
||||
} catch {
|
||||
// Native task mirroring must not corrupt the Copilot turn.
|
||||
}
|
||||
}
|
||||
|
||||
async function awaitStableCompaction(): Promise<void> {
|
||||
const idle = activeCompactionCount > 0 ? compactionIdle : undefined;
|
||||
if (idle) {
|
||||
@@ -456,6 +541,13 @@ function joinReasoning(order: string[], reasoningById: Map<string, string>): str
|
||||
return order.map((reasoningId) => reasoningById.get(reasoningId) ?? "").join("");
|
||||
}
|
||||
|
||||
function splitPlanText(text: string | undefined): string[] {
|
||||
return (text ?? "")
|
||||
.split(/\r?\n/)
|
||||
.map((line) => line.trim().replace(/^[-*]\s+/, ""))
|
||||
.filter((line) => line.length > 0);
|
||||
}
|
||||
|
||||
function readString(value: unknown): string | undefined {
|
||||
return typeof value === "string" && value.length > 0 ? value : undefined;
|
||||
}
|
||||
|
||||
200
extensions/copilot/src/native-subagent-task-mirror.test.ts
Normal file
200
extensions/copilot/src/native-subagent-task-mirror.test.ts
Normal file
@@ -0,0 +1,200 @@
|
||||
import type { SessionEvent } from "@github/copilot-sdk";
|
||||
import type {
|
||||
AgentHarnessTaskRecord,
|
||||
AgentHarnessTaskRuntime,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
CopilotNativeSubagentTaskMirror,
|
||||
createCopilotNativeSubagentTaskMirror,
|
||||
} from "./native-subagent-task-mirror.js";
|
||||
|
||||
type NativeSubagentEventType = "subagent.started" | "subagent.completed" | "subagent.failed";
|
||||
|
||||
function makeEvent<T extends NativeSubagentEventType>(
|
||||
type: T,
|
||||
data: Extract<SessionEvent, { type: T }>["data"],
|
||||
agentId?: string,
|
||||
): Extract<SessionEvent, { type: T }> {
|
||||
return {
|
||||
data,
|
||||
id: `${type}-id`,
|
||||
parentId: null,
|
||||
timestamp: "2024-01-01T00:00:00.000Z",
|
||||
type,
|
||||
...(agentId ? { agentId } : {}),
|
||||
} as Extract<SessionEvent, { type: T }>;
|
||||
}
|
||||
|
||||
function createRuntime() {
|
||||
const task = {} as AgentHarnessTaskRecord;
|
||||
return {
|
||||
tryCreateRunningTaskRun: vi.fn(() => task),
|
||||
recordTaskRunProgressByRunId: vi.fn(() => []),
|
||||
finalizeTaskRunByRunId: vi.fn(() => []),
|
||||
} satisfies Pick<
|
||||
AgentHarnessTaskRuntime,
|
||||
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
>;
|
||||
}
|
||||
|
||||
describe("CopilotNativeSubagentTaskMirror", () => {
|
||||
it("does not create a mirror without a host-issued task scope", () => {
|
||||
expect(createCopilotNativeSubagentTaskMirror({})).toBeUndefined();
|
||||
});
|
||||
|
||||
it("mirrors start and completion using agentId with toolCallId fallback", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror(
|
||||
{ agentId: "parent-agent", now: () => 100 },
|
||||
runtime,
|
||||
);
|
||||
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.started",
|
||||
{
|
||||
agentDescription: "inspect the repository",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-1",
|
||||
},
|
||||
"child-1",
|
||||
),
|
||||
);
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.completed",
|
||||
{
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-1",
|
||||
totalToolCalls: 2,
|
||||
totalTokens: 30,
|
||||
},
|
||||
"child-1",
|
||||
),
|
||||
);
|
||||
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
|
||||
sourceId: "call-1",
|
||||
agentId: "parent-agent",
|
||||
runId: "copilot-agent:child-1",
|
||||
label: "Researcher",
|
||||
task: "inspect the repository",
|
||||
notifyPolicy: "silent",
|
||||
deliveryStatus: "not_applicable",
|
||||
preferMetadata: true,
|
||||
startedAt: 100,
|
||||
lastEventAt: 100,
|
||||
progressSummary: "Copilot native subagent started.",
|
||||
});
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
runId: "copilot-agent:child-1",
|
||||
status: "succeeded",
|
||||
endedAt: 100,
|
||||
lastEventAt: 100,
|
||||
progressSummary: "Copilot native subagent completed.",
|
||||
terminalSummary: "Copilot native subagent completed (2 tool calls, 30 tokens).",
|
||||
});
|
||||
});
|
||||
|
||||
it("uses toolCallId when the SDK omits agentId", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 200 }, runtime);
|
||||
|
||||
mirror.handleEvent(
|
||||
makeEvent("subagent.started", {
|
||||
agentDescription: "",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-2",
|
||||
}),
|
||||
);
|
||||
mirror.handleEvent(
|
||||
makeEvent("subagent.failed", {
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
error: "failed",
|
||||
toolCallId: "call-2",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
runId: "copilot-agent:call-2",
|
||||
status: "failed",
|
||||
error: "failed",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps parallel subagents distinct when they share a parent tool call", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 250 }, runtime);
|
||||
|
||||
for (const agentId of ["child-1", "child-2"]) {
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.started",
|
||||
{
|
||||
agentDescription: `inspect ${agentId}`,
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-shared",
|
||||
},
|
||||
agentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
for (const agentId of ["child-1", "child-2"]) {
|
||||
mirror.handleEvent(
|
||||
makeEvent(
|
||||
"subagent.completed",
|
||||
{
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-shared",
|
||||
},
|
||||
agentId,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledTimes(2);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
expect.objectContaining({ runId: "copilot-agent:child-1" }),
|
||||
);
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
expect.objectContaining({ runId: "copilot-agent:child-2" }),
|
||||
);
|
||||
});
|
||||
|
||||
it("finalizes active tasks when the parent attempt tears down", () => {
|
||||
const runtime = createRuntime();
|
||||
const mirror = new CopilotNativeSubagentTaskMirror({ now: () => 300 }, runtime);
|
||||
|
||||
mirror.handleEvent(
|
||||
makeEvent("subagent.started", {
|
||||
agentDescription: "inspect",
|
||||
agentDisplayName: "Researcher",
|
||||
agentName: "researcher",
|
||||
toolCallId: "call-3",
|
||||
}),
|
||||
);
|
||||
mirror.finalizeActiveRuns();
|
||||
|
||||
expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
|
||||
runId: "copilot-agent:call-3",
|
||||
status: "cancelled",
|
||||
endedAt: 300,
|
||||
lastEventAt: 300,
|
||||
error: "Copilot native subagent ended with its parent attempt.",
|
||||
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
|
||||
terminalSummary: "Copilot native subagent cancelled.",
|
||||
});
|
||||
});
|
||||
});
|
||||
199
extensions/copilot/src/native-subagent-task-mirror.ts
Normal file
199
extensions/copilot/src/native-subagent-task-mirror.ts
Normal file
@@ -0,0 +1,199 @@
|
||||
import type { SessionEvent } from "@github/copilot-sdk";
|
||||
import {
|
||||
createAgentHarnessTaskRuntime,
|
||||
type AgentHarnessTaskRuntime,
|
||||
type AgentHarnessTaskRuntimeScope,
|
||||
} from "openclaw/plugin-sdk/agent-harness-task-runtime";
|
||||
|
||||
const COPILOT_NATIVE_SUBAGENT_TASK_KIND = "copilot-native";
|
||||
const COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX = "copilot-agent:";
|
||||
|
||||
type CopilotNativeSubagentEvent = Extract<
|
||||
SessionEvent,
|
||||
{ type: "subagent.started" | "subagent.completed" | "subagent.failed" }
|
||||
>;
|
||||
|
||||
type TaskLifecycleRuntime = Pick<
|
||||
AgentHarnessTaskRuntime,
|
||||
"tryCreateRunningTaskRun" | "recordTaskRunProgressByRunId" | "finalizeTaskRunByRunId"
|
||||
>;
|
||||
|
||||
export function createCopilotNativeSubagentTaskMirror(params: {
|
||||
agentId?: string;
|
||||
now?: () => number;
|
||||
scope?: AgentHarnessTaskRuntimeScope;
|
||||
}): CopilotNativeSubagentTaskMirror | undefined {
|
||||
if (!params.scope) {
|
||||
return undefined;
|
||||
}
|
||||
return new CopilotNativeSubagentTaskMirror(
|
||||
{
|
||||
agentId: params.agentId,
|
||||
now: params.now,
|
||||
},
|
||||
createAgentHarnessTaskRuntime({
|
||||
runtime: "subagent",
|
||||
taskKind: COPILOT_NATIVE_SUBAGENT_TASK_KIND,
|
||||
scope: params.scope,
|
||||
runIdPrefix: COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export class CopilotNativeSubagentTaskMirror {
|
||||
private readonly runIdByAgentId = new Map<string, string>();
|
||||
private readonly runIdByToolCallId = new Map<string, string>();
|
||||
private readonly terminalRunIds = new Set<string>();
|
||||
private readonly activeRunIds = new Set<string>();
|
||||
private readonly now: () => number;
|
||||
|
||||
constructor(
|
||||
private readonly params: { agentId?: string; now?: () => number },
|
||||
private readonly runtime: TaskLifecycleRuntime,
|
||||
) {
|
||||
this.now = params.now ?? Date.now;
|
||||
}
|
||||
|
||||
handleEvent(event: CopilotNativeSubagentEvent): void {
|
||||
const toolCallId = event.data.toolCallId.trim();
|
||||
if (!toolCallId) {
|
||||
return;
|
||||
}
|
||||
const runId = this.resolveRunId(event);
|
||||
if (event.type === "subagent.started") {
|
||||
this.handleStarted(event, runId, toolCallId);
|
||||
return;
|
||||
}
|
||||
if (event.type === "subagent.completed") {
|
||||
this.handleCompleted(event, runId);
|
||||
return;
|
||||
}
|
||||
this.handleFailed(event, runId);
|
||||
}
|
||||
|
||||
finalizeActiveRuns(): void {
|
||||
const eventAt = this.now();
|
||||
for (const runId of this.activeRunIds) {
|
||||
this.terminalRunIds.add(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "cancelled",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
error: "Copilot native subagent ended with its parent attempt.",
|
||||
progressSummary: "Copilot native subagent cancelled with its parent attempt.",
|
||||
terminalSummary: "Copilot native subagent cancelled.",
|
||||
});
|
||||
}
|
||||
this.activeRunIds.clear();
|
||||
}
|
||||
|
||||
private handleStarted(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.started" }>,
|
||||
runId: string,
|
||||
toolCallId: string,
|
||||
): void {
|
||||
const agentId = event.agentId?.trim();
|
||||
const existingRunId = agentId
|
||||
? this.runIdByAgentId.get(agentId)
|
||||
: this.runIdByToolCallId.get(toolCallId);
|
||||
if (existingRunId) {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
const label = event.data.agentDisplayName.trim() || event.data.agentName.trim();
|
||||
const task = event.data.agentDescription.trim() || `Copilot native subagent ${label}`;
|
||||
const taskRecord = this.runtime.tryCreateRunningTaskRun({
|
||||
sourceId: toolCallId,
|
||||
agentId: this.params.agentId,
|
||||
runId,
|
||||
label: label || "Copilot subagent",
|
||||
task,
|
||||
notifyPolicy: "silent",
|
||||
deliveryStatus: "not_applicable",
|
||||
preferMetadata: true,
|
||||
startedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: "Copilot native subagent started.",
|
||||
});
|
||||
if (!taskRecord) {
|
||||
return;
|
||||
}
|
||||
if (agentId) {
|
||||
this.runIdByAgentId.set(agentId, runId);
|
||||
} else {
|
||||
this.runIdByToolCallId.set(toolCallId, runId);
|
||||
}
|
||||
this.terminalRunIds.delete(runId);
|
||||
this.activeRunIds.add(runId);
|
||||
}
|
||||
|
||||
private handleCompleted(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
|
||||
runId: string,
|
||||
): void {
|
||||
if (this.terminalRunIds.has(runId)) {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
this.terminalRunIds.add(runId);
|
||||
this.activeRunIds.delete(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "succeeded",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
progressSummary: "Copilot native subagent completed.",
|
||||
terminalSummary: buildCompletionSummary(event),
|
||||
});
|
||||
}
|
||||
|
||||
private handleFailed(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.failed" }>,
|
||||
runId: string,
|
||||
): void {
|
||||
if (this.terminalRunIds.has(runId)) {
|
||||
return;
|
||||
}
|
||||
const eventAt = this.now();
|
||||
this.terminalRunIds.add(runId);
|
||||
this.activeRunIds.delete(runId);
|
||||
this.runtime.finalizeTaskRunByRunId({
|
||||
runId,
|
||||
status: "failed",
|
||||
endedAt: eventAt,
|
||||
lastEventAt: eventAt,
|
||||
error: event.data.error,
|
||||
progressSummary: "Copilot native subagent failed.",
|
||||
terminalSummary: "Copilot native subagent failed.",
|
||||
});
|
||||
}
|
||||
|
||||
private resolveRunId(event: CopilotNativeSubagentEvent): string {
|
||||
const agentId = event.agentId?.trim();
|
||||
if (agentId) {
|
||||
const existing = this.runIdByAgentId.get(agentId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
}
|
||||
const existing = this.runIdByToolCallId.get(event.data.toolCallId);
|
||||
if (existing) {
|
||||
return existing;
|
||||
}
|
||||
const identity = agentId || event.data.toolCallId.trim();
|
||||
return `${COPILOT_NATIVE_SUBAGENT_RUN_ID_PREFIX}${identity}`;
|
||||
}
|
||||
}
|
||||
|
||||
function buildCompletionSummary(
|
||||
event: Extract<CopilotNativeSubagentEvent, { type: "subagent.completed" }>,
|
||||
): string {
|
||||
const details = [
|
||||
event.data.totalToolCalls !== undefined ? `${event.data.totalToolCalls} tool calls` : undefined,
|
||||
event.data.totalTokens !== undefined ? `${event.data.totalTokens} tokens` : undefined,
|
||||
].filter((value): value is string => value !== undefined);
|
||||
return details.length > 0
|
||||
? `Copilot native subagent completed (${details.join(", ")}).`
|
||||
: "Copilot native subagent completed.";
|
||||
}
|
||||
@@ -156,11 +156,184 @@ describe("createCopilotToolBridge", () => {
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(result.sourceTools).toBe(sourceTools);
|
||||
expect(result.sourceTools).toEqual(sourceTools);
|
||||
expect(result.sdkTools).toHaveLength(2);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool-a", "tool-b"]);
|
||||
});
|
||||
|
||||
it("compacts the Copilot tool surface behind tool_search controls when enabled", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
|
||||
const includeToolSearchControls = Boolean(
|
||||
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
|
||||
);
|
||||
return includeToolSearchControls
|
||||
? [
|
||||
makeTool({ name: "tool_search_code" }),
|
||||
makeTool({ name: "fake_hidden" }),
|
||||
makeTool({ name: "read" }),
|
||||
]
|
||||
: [makeTool({ name: "fake_hidden" }), makeTool({ name: "read" })];
|
||||
});
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { toolSearch: true } },
|
||||
runId: "run-tool-search",
|
||||
sessionKey: "agent:main:main",
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeToolSearchControls: true,
|
||||
toolSearchCatalogRef: expect.any(Object),
|
||||
toolSearchCatalogExecutor: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
|
||||
});
|
||||
|
||||
it("keeps tool_search controls visible when a narrow allowlist is active", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
|
||||
const includeToolSearchControls = Boolean(
|
||||
(opts as { includeToolSearchControls?: boolean }).includeToolSearchControls,
|
||||
);
|
||||
return includeToolSearchControls
|
||||
? [makeTool({ name: "tool_search_code" }), makeTool({ name: "read" })]
|
||||
: [makeTool({ name: "read" })];
|
||||
});
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { toolSearch: true } },
|
||||
runId: "run-tool-search",
|
||||
sessionKey: "agent:main:main",
|
||||
toolsAllow: ["read"],
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["tool_search_code"]);
|
||||
});
|
||||
|
||||
it("filters the hidden tool_search catalog before compacting narrowed tools", async () => {
|
||||
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
|
||||
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
|
||||
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
|
||||
return [
|
||||
makeTool({ name: "tool_search_code" }),
|
||||
makeTool({ name: "read" }),
|
||||
makeTool({ name: "edit" }),
|
||||
makeTool({ name: "write" }),
|
||||
];
|
||||
});
|
||||
|
||||
await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { toolSearch: true } },
|
||||
runId: "run-tool-search",
|
||||
sessionKey: "agent:main:main",
|
||||
toolsAllow: ["read"],
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("compacts the Copilot tool surface behind code-mode exec/wait when enabled", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async () => [
|
||||
makeTool({ name: "fake_hidden" }),
|
||||
makeTool({ name: "read" }),
|
||||
]);
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { codeMode: true } },
|
||||
runId: "run-code-mode",
|
||||
sessionKey: "agent:main:main",
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(createOpenClawCodingTools).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
includeToolSearchControls: false,
|
||||
toolSearchCatalogRef: expect.any(Object),
|
||||
toolSearchCatalogExecutor: expect.any(Function),
|
||||
}),
|
||||
);
|
||||
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
});
|
||||
|
||||
it("keeps code-mode controls visible when a narrow allowlist is active", async () => {
|
||||
const createOpenClawCodingTools = vi.fn(async () => [
|
||||
makeTool({ name: "fake_hidden" }),
|
||||
makeTool({ name: "read" }),
|
||||
]);
|
||||
|
||||
const result = await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { codeMode: true } },
|
||||
runId: "run-code-mode",
|
||||
sessionKey: "agent:main:main",
|
||||
toolsAllow: ["read"],
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(result.sourceTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
expect(result.sdkTools.map((tool) => tool.name)).toEqual(["exec", "wait"]);
|
||||
});
|
||||
|
||||
it("filters the hidden code-mode catalog before compacting narrowed tools", async () => {
|
||||
let catalogRef: { current?: { entries?: Array<{ name: string }> } } | undefined;
|
||||
const createOpenClawCodingTools = vi.fn(async (opts: unknown) => {
|
||||
catalogRef = (opts as { toolSearchCatalogRef?: typeof catalogRef }).toolSearchCatalogRef;
|
||||
return [makeTool({ name: "read" }), makeTool({ name: "edit" }), makeTool({ name: "write" })];
|
||||
});
|
||||
|
||||
await createCopilotToolBridge({
|
||||
agentId: "agent-1",
|
||||
attemptParams: {
|
||||
config: { tools: { codeMode: true } },
|
||||
runId: "run-code-mode",
|
||||
sessionKey: "agent:main:main",
|
||||
toolsAllow: ["read"],
|
||||
} as never,
|
||||
createOpenClawCodingTools,
|
||||
modelId: "gpt-4o",
|
||||
modelProvider: "github-copilot",
|
||||
sessionId: "session-1",
|
||||
});
|
||||
|
||||
expect(catalogRef?.current?.entries?.map((entry) => entry.name)).toEqual(["read"]);
|
||||
});
|
||||
|
||||
it("throws when createOpenClawCodingTools returns a non-array", async () => {
|
||||
await expect(
|
||||
createCopilotToolBridge({
|
||||
|
||||
@@ -17,10 +17,15 @@ import {
|
||||
resolveModelAuthMode,
|
||||
sanitizeToolResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { createAgentHarnessToolSurfaceRuntime } from "openclaw/plugin-sdk/agent-harness-tool-runtime";
|
||||
|
||||
type CreateOpenClawCodingTools =
|
||||
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
|
||||
type OpenClawCodingToolsOptions = NonNullable<Parameters<CreateOpenClawCodingTools>[0]>;
|
||||
type AgentHarnessToolSurfaceRuntime = ReturnType<typeof createAgentHarnessToolSurfaceRuntime>;
|
||||
type CatalogExecuteParams = Parameters<
|
||||
NonNullable<AgentHarnessToolSurfaceRuntime["toolSearchCatalogExecutor"]>
|
||||
>[0];
|
||||
|
||||
type AgentToolResultLike = {
|
||||
content?: unknown;
|
||||
@@ -130,6 +135,7 @@ export interface CopilotToolBridgeInput {
|
||||
}
|
||||
|
||||
export interface CopilotToolBridge {
|
||||
cleanup?: () => void;
|
||||
sdkTools: SdkTool[];
|
||||
sourceTools: AnyAgentTool[];
|
||||
}
|
||||
@@ -178,7 +184,31 @@ export async function createCopilotToolBridge(
|
||||
input.createOpenClawCodingTools ??
|
||||
(await import("openclaw/plugin-sdk/agent-harness")).createOpenClawCodingTools;
|
||||
|
||||
const toolOptions = buildOpenClawCodingToolsOptions(input, effectiveToolPlan);
|
||||
const toolSurfaceRuntime = createAgentHarnessToolSurfaceRuntime({
|
||||
abortSignal: input.abortSignal,
|
||||
agentId: input.agentId,
|
||||
config: attemptParams.config,
|
||||
disableTools: attemptParams.disableTools,
|
||||
executeTool: (toolParams) => executeCatalogTool(input, toolParams),
|
||||
forceMessageTool: shouldForceCopilotMessageTool(attemptParams),
|
||||
isRawModelRun: isCopilotRawModelRun(attemptParams),
|
||||
modelToolsEnabled: true,
|
||||
prompt: attemptParams.prompt,
|
||||
runId: attemptParams.runId,
|
||||
runtimeToolAllowlist: effectiveToolPlan.runtimeToolAllowlist,
|
||||
sessionId: input.sessionId,
|
||||
sessionKey: attemptParams.sandboxSessionKey ?? attemptParams.sessionKey ?? input.sessionKey,
|
||||
sourceReplyDeliveryMode: attemptParams.sourceReplyDeliveryMode,
|
||||
toolsAllow: attemptParams.toolsAllow,
|
||||
});
|
||||
const toolOptions = buildOpenClawCodingToolsOptions(
|
||||
input,
|
||||
{
|
||||
...effectiveToolPlan,
|
||||
runtimeToolAllowlist: toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
},
|
||||
toolSurfaceRuntime,
|
||||
);
|
||||
|
||||
let sourceTools: unknown;
|
||||
try {
|
||||
@@ -196,13 +226,19 @@ export async function createCopilotToolBridge(
|
||||
);
|
||||
}
|
||||
|
||||
const plannedTools = filterCopilotToolsForConstructionPlan(
|
||||
const allowedSourceTools = filterCopilotToolsForAllowlist(
|
||||
sourceTools as AnyAgentTool[],
|
||||
toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
);
|
||||
const compactedTools = toolSurfaceRuntime.compactTools(allowedSourceTools);
|
||||
const plannedTools = filterCopilotToolsForConstructionPlan(
|
||||
compactedTools.tools,
|
||||
effectiveToolPlan.codingToolConstructionPlan,
|
||||
{ preserveToolNames: toolSurfaceRuntime.runtimeToolAllowlist },
|
||||
);
|
||||
const filteredTools = filterCopilotToolsForAllowlist(
|
||||
plannedTools,
|
||||
effectiveToolPlan.runtimeToolAllowlist,
|
||||
toolSurfaceRuntime.runtimeToolAllowlist,
|
||||
);
|
||||
|
||||
// Run duplicate detection after filtering so a duplicate in a
|
||||
@@ -214,6 +250,7 @@ export async function createCopilotToolBridge(
|
||||
}
|
||||
|
||||
return {
|
||||
cleanup: toolSurfaceRuntime.cleanup,
|
||||
sdkTools: filteredTools.map((sourceTool) =>
|
||||
convertOpenClawToolToSdkTool(sourceTool, {
|
||||
abortSignal: input.abortSignal,
|
||||
@@ -251,6 +288,7 @@ export async function createCopilotToolBridge(
|
||||
function buildOpenClawCodingToolsOptions(
|
||||
input: CopilotToolBridgeInput,
|
||||
toolPlan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>,
|
||||
toolSurfaceRuntime?: ReturnType<typeof createAgentHarnessToolSurfaceRuntime>,
|
||||
): OpenClawCodingToolsOptions {
|
||||
const a = input.attemptParams ?? ({} as CopilotToolAttemptParams);
|
||||
|
||||
@@ -339,11 +377,14 @@ function buildOpenClawCodingToolsOptions(
|
||||
// `resolveSandboxContext`).
|
||||
sandbox,
|
||||
spawnWorkspaceDir,
|
||||
config: a.config,
|
||||
config: toolSurfaceRuntime?.config ?? a.config,
|
||||
abortSignal: input.abortSignal,
|
||||
modelProvider: input.modelProvider,
|
||||
modelId: input.modelId,
|
||||
includeCoreTools: toolPlan.includeCoreTools,
|
||||
includeToolSearchControls: toolSurfaceRuntime?.includeToolSearchControls,
|
||||
toolSearchCatalogRef: toolSurfaceRuntime?.toolSearchCatalogRef,
|
||||
toolSearchCatalogExecutor: toolSurfaceRuntime?.toolSearchCatalogExecutor,
|
||||
runtimeToolAllowlist: toolPlan.runtimeToolAllowlist,
|
||||
toolConstructionPlan: toolPlan.codingToolConstructionPlan,
|
||||
modelCompat,
|
||||
@@ -575,6 +616,63 @@ export function convertOpenClawToolToSdkTool(
|
||||
};
|
||||
}
|
||||
|
||||
async function executeCatalogTool(
|
||||
input: CopilotToolBridgeInput,
|
||||
params: CatalogExecuteParams,
|
||||
): Promise<Awaited<ReturnType<AnyAgentTool["execute"]>>> {
|
||||
const sourceTool = params.tool as AnyAgentTool;
|
||||
const startedAt = Date.now();
|
||||
let preparedArgs: unknown = params.input;
|
||||
try {
|
||||
preparedArgs = sourceTool.prepareArguments
|
||||
? sourceTool.prepareArguments(params.input)
|
||||
: params.input;
|
||||
const result = await sourceTool.execute(
|
||||
params.toolCallId,
|
||||
preparedArgs,
|
||||
params.signal ?? input.abortSignal,
|
||||
params.onUpdate,
|
||||
);
|
||||
const sanitizedResult = sanitizeToolResult(result);
|
||||
const isError = isToolResultError(sanitizedResult);
|
||||
input.attemptParams?.onAgentToolResult?.({
|
||||
toolName: params.toolName,
|
||||
result: sanitizedResult,
|
||||
isError,
|
||||
});
|
||||
await input.onToolCompleted?.({
|
||||
toolName: params.toolName,
|
||||
toolCallId: params.toolCallId,
|
||||
args: toToolStartArgs(preparedArgs),
|
||||
result: sanitizedResult,
|
||||
...(isError
|
||||
? { error: extractToolErrorMessage(sanitizedResult) ?? "tool returned an error" }
|
||||
: {}),
|
||||
startedAt,
|
||||
});
|
||||
return result;
|
||||
} catch (error: unknown) {
|
||||
const message = toError(error).message;
|
||||
const failure = sanitizeToolResult({
|
||||
content: [{ type: "text", text: message }],
|
||||
details: { status: "failed", error: message },
|
||||
});
|
||||
input.attemptParams?.onAgentToolResult?.({
|
||||
toolName: params.toolName,
|
||||
result: failure,
|
||||
isError: true,
|
||||
});
|
||||
await input.onToolCompleted?.({
|
||||
toolName: params.toolName,
|
||||
toolCallId: params.toolCallId,
|
||||
args: toToolStartArgs(preparedArgs),
|
||||
error: message,
|
||||
startedAt,
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
function toToolStartArgs(args: unknown): Record<string, unknown> {
|
||||
return args && typeof args === "object" && !Array.isArray(args)
|
||||
? (args as Record<string, unknown>)
|
||||
@@ -712,11 +810,16 @@ function filterCopilotToolsForAllowlist<T extends { name: string }>(
|
||||
function filterCopilotToolsForConstructionPlan<T extends { name: string }>(
|
||||
tools: T[],
|
||||
plan: ReturnType<typeof resolveEmbeddedAttemptToolConstructionPlan>["codingToolConstructionPlan"],
|
||||
options: { preserveToolNames?: readonly string[] } = {},
|
||||
): T[] {
|
||||
if (plan.includeBaseCodingTools && plan.includeShellTools) {
|
||||
return tools;
|
||||
}
|
||||
const preserveToolNames = new Set(options.preserveToolNames);
|
||||
return tools.filter((tool) => {
|
||||
if (preserveToolNames.has(tool.name)) {
|
||||
return true;
|
||||
}
|
||||
if (!plan.includeBaseCodingTools && BASE_COPILOT_CODING_TOOL_NAMES.has(tool.name)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
121
extensions/copilot/src/user-input-bridge.test.ts
Normal file
121
extensions/copilot/src/user-input-bridge.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
// Copilot tests cover SDK ask_user bridge behavior.
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createCopilotUserInputBridge } from "./user-input-bridge.js";
|
||||
|
||||
function createParams(): EmbeddedRunAttemptParams {
|
||||
return {
|
||||
sessionId: "session-1",
|
||||
sessionKey: "agent:main:session-1",
|
||||
onBlockReply: vi.fn(),
|
||||
} as unknown as EmbeddedRunAttemptParams;
|
||||
}
|
||||
|
||||
function expectFirstBlockReplyText(params: EmbeddedRunAttemptParams): string {
|
||||
const onBlockReply = params.onBlockReply;
|
||||
if (!onBlockReply) {
|
||||
throw new Error("Expected onBlockReply callback");
|
||||
}
|
||||
const payload = vi.mocked(onBlockReply).mock.calls[0]?.[0];
|
||||
if (typeof payload?.text !== "string") {
|
||||
throw new Error("Expected first block reply text");
|
||||
}
|
||||
return payload.text;
|
||||
}
|
||||
|
||||
describe("Copilot user input bridge", () => {
|
||||
it("prompts through OpenClaw and resolves the SDK request from the next queued message", async () => {
|
||||
const params = createParams();
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
|
||||
|
||||
const response = bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Pick a mode",
|
||||
choices: ["Fast", "Deep"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
expect(expectFirstBlockReplyText(params)).toContain("Pick a mode");
|
||||
expect(bridge.handleQueuedMessage("2")).toBe(true);
|
||||
|
||||
await expect(response).resolves.toEqual({ answer: "Deep", wasFreeform: false });
|
||||
});
|
||||
|
||||
it("returns free-form answers when Copilot allows them", async () => {
|
||||
const params = createParams();
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
|
||||
|
||||
const response = bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Which branch?",
|
||||
allowFreeform: true,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
expect(bridge.handleQueuedMessage("fix/harness-parity")).toBe(true);
|
||||
|
||||
await expect(response).resolves.toEqual({
|
||||
answer: "fix/harness-parity",
|
||||
wasFreeform: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("escapes SDK-controlled prompt text before channel delivery", async () => {
|
||||
const params = createParams();
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: params });
|
||||
|
||||
void bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Pick [trusted](https://evil) <@U123> @here\u202e",
|
||||
choices: ["One @everyone", "Two `code`"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
const text = expectFirstBlockReplyText(params);
|
||||
expect(text).not.toContain("@here");
|
||||
expect(text).not.toContain("@everyone");
|
||||
expect(text).not.toContain("<@U123>");
|
||||
expect(text).not.toContain("[trusted](https://evil)");
|
||||
expect(text).not.toContain("`code`");
|
||||
expect(text).toContain("\uff20here");
|
||||
expect(text).toContain("\uff3btrusted\uff3d");
|
||||
});
|
||||
|
||||
it("rejects queued messages when no ask_user request is pending", () => {
|
||||
const bridge = createCopilotUserInputBridge({ paramsForRun: createParams() });
|
||||
|
||||
expect(bridge.handleQueuedMessage("late")).toBe(false);
|
||||
});
|
||||
|
||||
it("resolves pending requests with an empty answer when aborted", async () => {
|
||||
const params = createParams();
|
||||
const controller = new AbortController();
|
||||
const bridge = createCopilotUserInputBridge({
|
||||
paramsForRun: params,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
const response = bridge.onUserInputRequest(
|
||||
{
|
||||
question: "Continue?",
|
||||
choices: ["Yes", "No"],
|
||||
allowFreeform: false,
|
||||
},
|
||||
{ sessionId: "sdk-session-1" },
|
||||
);
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1));
|
||||
controller.abort();
|
||||
|
||||
await expect(response).resolves.toEqual({ answer: "", wasFreeform: true });
|
||||
expect(bridge.handleQueuedMessage("1")).toBe(false);
|
||||
});
|
||||
});
|
||||
161
extensions/copilot/src/user-input-bridge.ts
Normal file
161
extensions/copilot/src/user-input-bridge.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import type { SessionConfig } from "@github/copilot-sdk";
|
||||
import {
|
||||
buildAgentHarnessUserInputAnswers,
|
||||
deliverAgentHarnessUserInputPrompt,
|
||||
embeddedAgentLog,
|
||||
type AgentHarnessUserInputQuestion,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
|
||||
type PendingCopilotUserInput = {
|
||||
question: AgentHarnessUserInputQuestion;
|
||||
resolve: (value: CopilotUserInputResponse) => void;
|
||||
cleanup: () => void;
|
||||
};
|
||||
|
||||
type CopilotUserInputHandler = NonNullable<SessionConfig["onUserInputRequest"]>;
|
||||
type CopilotUserInputRequest = Parameters<CopilotUserInputHandler>[0];
|
||||
type CopilotUserInputResponse = Awaited<ReturnType<CopilotUserInputHandler>>;
|
||||
|
||||
type CopilotUserInputBridge = {
|
||||
onUserInputRequest: CopilotUserInputHandler;
|
||||
handleQueuedMessage: (text: string) => boolean;
|
||||
cancelPending: () => void;
|
||||
};
|
||||
|
||||
const COPILOT_USER_INPUT_QUESTION_ID = "answer";
|
||||
|
||||
export function createCopilotUserInputBridge(params: {
|
||||
paramsForRun: EmbeddedRunAttemptParams;
|
||||
signal?: AbortSignal;
|
||||
}): CopilotUserInputBridge {
|
||||
let pending: PendingCopilotUserInput | undefined;
|
||||
|
||||
const resolvePending = (value: CopilotUserInputResponse) => {
|
||||
const current = pending;
|
||||
if (!current) {
|
||||
return;
|
||||
}
|
||||
pending = undefined;
|
||||
current.cleanup();
|
||||
current.resolve(value);
|
||||
};
|
||||
|
||||
return {
|
||||
onUserInputRequest(request) {
|
||||
const question = toQuestion(request);
|
||||
resolvePending(emptyCopilotUserInputResponse());
|
||||
return new Promise<CopilotUserInputResponse>((resolve) => {
|
||||
const abortListener = () => resolvePending(emptyCopilotUserInputResponse());
|
||||
const cleanup = () => params.signal?.removeEventListener("abort", abortListener);
|
||||
pending = { question, resolve, cleanup };
|
||||
params.signal?.addEventListener("abort", abortListener, { once: true });
|
||||
if (params.signal?.aborted) {
|
||||
resolvePending(emptyCopilotUserInputResponse());
|
||||
return;
|
||||
}
|
||||
void deliverAgentHarnessUserInputPrompt(params.paramsForRun, [question], {
|
||||
intro: "Copilot needs input:",
|
||||
formatText: formatCopilotDisplayText,
|
||||
}).catch((error: unknown) => {
|
||||
embeddedAgentLog.warn("failed to deliver copilot user input prompt", { error });
|
||||
});
|
||||
});
|
||||
},
|
||||
handleQueuedMessage(text) {
|
||||
const current = pending;
|
||||
if (!current) {
|
||||
return false;
|
||||
}
|
||||
resolvePending(buildCopilotUserInputResponse(current.question, text));
|
||||
return true;
|
||||
},
|
||||
cancelPending() {
|
||||
resolvePending(emptyCopilotUserInputResponse());
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function toQuestion(request: CopilotUserInputRequest): AgentHarnessUserInputQuestion {
|
||||
return {
|
||||
id: COPILOT_USER_INPUT_QUESTION_ID,
|
||||
header: "Copilot needs input",
|
||||
question: request.question,
|
||||
isOther: request.allowFreeform !== false,
|
||||
isSecret: false,
|
||||
options:
|
||||
request.choices && request.choices.length > 0
|
||||
? request.choices.map((choice: string) => ({ label: choice }))
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
function buildCopilotUserInputResponse(
|
||||
question: AgentHarnessUserInputQuestion,
|
||||
inputText: string,
|
||||
): CopilotUserInputResponse {
|
||||
const rawAnswers = buildAgentHarnessUserInputAnswers([question], inputText);
|
||||
const selected = rawAnswers.answers[COPILOT_USER_INPUT_QUESTION_ID]?.answers[0] ?? "";
|
||||
return {
|
||||
answer: selected,
|
||||
wasFreeform: !isChoiceAnswer(question, selected),
|
||||
};
|
||||
}
|
||||
|
||||
function emptyCopilotUserInputResponse(): CopilotUserInputResponse {
|
||||
return { answer: "", wasFreeform: true };
|
||||
}
|
||||
|
||||
function isChoiceAnswer(question: AgentHarnessUserInputQuestion, answer: string): boolean {
|
||||
return Boolean(
|
||||
answer &&
|
||||
question.options?.some((option) => option.label.toLowerCase() === answer.toLowerCase()),
|
||||
);
|
||||
}
|
||||
|
||||
function formatCopilotDisplayText(value: string): string {
|
||||
const safe = sanitizeCopilotDisplayText(value).trim();
|
||||
return escapeCopilotChatText(safe || "<unknown>");
|
||||
}
|
||||
|
||||
function sanitizeCopilotDisplayText(value: string): string {
|
||||
let safe = "";
|
||||
for (const character of value) {
|
||||
const codePoint = character.codePointAt(0);
|
||||
safe += codePoint != null && isUnsafeDisplayCodePoint(codePoint) ? "?" : character;
|
||||
}
|
||||
return safe;
|
||||
}
|
||||
|
||||
function escapeCopilotChatText(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll("@", "\uff20")
|
||||
.replaceAll("`", "\uff40")
|
||||
.replaceAll("[", "\uff3b")
|
||||
.replaceAll("]", "\uff3d")
|
||||
.replaceAll("(", "\uff08")
|
||||
.replaceAll(")", "\uff09")
|
||||
.replaceAll("*", "\u2217")
|
||||
.replaceAll("_", "\uff3f")
|
||||
.replaceAll("~", "\uff5e")
|
||||
.replaceAll("|", "\uff5c");
|
||||
}
|
||||
|
||||
function isUnsafeDisplayCodePoint(codePoint: number): boolean {
|
||||
return (
|
||||
codePoint <= 0x001f ||
|
||||
(codePoint >= 0x007f && codePoint <= 0x009f) ||
|
||||
codePoint === 0x00ad ||
|
||||
codePoint === 0x061c ||
|
||||
codePoint === 0x180e ||
|
||||
(codePoint >= 0x200b && codePoint <= 0x200f) ||
|
||||
(codePoint >= 0x202a && codePoint <= 0x202e) ||
|
||||
(codePoint >= 0x2060 && codePoint <= 0x206f) ||
|
||||
codePoint === 0xfeff ||
|
||||
(codePoint >= 0xfff9 && codePoint <= 0xfffb) ||
|
||||
(codePoint >= 0xe0000 && codePoint <= 0xe007f)
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "deepgram",
|
||||
"icon": "https://cdn.simpleicons.org/deepgram/111111",
|
||||
"icon": "https://cdn.simpleicons.org/deepgram",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user