mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
168 Commits
v2026.6.1-
...
codex/cron
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
feebf7a1e6 | ||
|
|
732ceadfb7 | ||
|
|
41339c6370 | ||
|
|
25f3c2a22b | ||
|
|
6d5061c234 | ||
|
|
286e5ffe07 | ||
|
|
158c4d7540 | ||
|
|
344e04b5d5 | ||
|
|
ec47d1cdd5 | ||
|
|
8c89d35a8a | ||
|
|
d358294f89 | ||
|
|
3480832614 | ||
|
|
e0ab71d3dc | ||
|
|
21b262f507 | ||
|
|
3a64302585 | ||
|
|
38f1db6d67 | ||
|
|
8f6f2617ec | ||
|
|
f4868b79e3 | ||
|
|
d3ab7e92ef | ||
|
|
acacd32415 | ||
|
|
0b26a1bca7 | ||
|
|
0bcdb9c0d1 | ||
|
|
946eed685d | ||
|
|
c219c62598 | ||
|
|
5483ff705f | ||
|
|
70a989a97a | ||
|
|
b7450f83a1 | ||
|
|
ff5667a582 | ||
|
|
d6bea4c5ac | ||
|
|
79896a24d9 | ||
|
|
a7d5ae1872 | ||
|
|
446a2b24c3 | ||
|
|
e4993ec00f | ||
|
|
90493ee8e2 | ||
|
|
60dcaa3cf5 | ||
|
|
b3b203bf67 | ||
|
|
0a4927d0b8 | ||
|
|
a61c94b1f1 | ||
|
|
a9f099d279 | ||
|
|
2fa60af960 | ||
|
|
07006943de | ||
|
|
9dc1694eb7 | ||
|
|
98ff56d70e | ||
|
|
03ccdb9fbc | ||
|
|
6d7b80fa1c | ||
|
|
409d1a7135 | ||
|
|
d31f4e2d62 | ||
|
|
e5e6cf04a2 | ||
|
|
4f8740029a | ||
|
|
9159b3bf8e | ||
|
|
eddf1c776d | ||
|
|
6ec579a0c2 | ||
|
|
87eaac4010 | ||
|
|
529282dcff | ||
|
|
b1fccd0605 | ||
|
|
287dee4593 | ||
|
|
b96c0d932f | ||
|
|
a46181f168 | ||
|
|
1b5cb4a0d3 | ||
|
|
9947a26768 | ||
|
|
2accf3875b | ||
|
|
76c8b36031 | ||
|
|
44fea3c94a | ||
|
|
c68938c19e | ||
|
|
a7c8b2a46a | ||
|
|
5a0d9d6326 | ||
|
|
7cee0bca0b | ||
|
|
7074cf8e23 | ||
|
|
26301f318f | ||
|
|
f49f5973b0 | ||
|
|
1e4ff80604 | ||
|
|
84dca54ef2 | ||
|
|
4a67e4b976 | ||
|
|
41ee6b1dd6 | ||
|
|
04f93c2fb4 | ||
|
|
3cdb87be86 | ||
|
|
17a285f298 | ||
|
|
c2d7b4a486 | ||
|
|
0b98aea71a | ||
|
|
114864185b | ||
|
|
1bd1483b62 | ||
|
|
a5ef086e3c | ||
|
|
a10faca06f | ||
|
|
380a8f140e | ||
|
|
34c3827290 | ||
|
|
54fe0e7f71 | ||
|
|
932d6ea8e5 | ||
|
|
d004b80c91 | ||
|
|
5820378b90 | ||
|
|
d5df1a1cd6 | ||
|
|
175cfe4846 | ||
|
|
85e5d486df | ||
|
|
b6cee3fc35 | ||
|
|
d48b9274d8 | ||
|
|
6d788a237c | ||
|
|
7ccbffcb1b | ||
|
|
2c92973398 | ||
|
|
ed4c4afc0f | ||
|
|
a462601f05 | ||
|
|
f472778717 | ||
|
|
7c1a83ff2e | ||
|
|
f8fcb35064 | ||
|
|
c0b05a2100 | ||
|
|
2a512025ad | ||
|
|
7f79bd8683 | ||
|
|
a4b09d72b9 | ||
|
|
58160094e8 | ||
|
|
c0c4156b6d | ||
|
|
3f66797578 | ||
|
|
f02c1209aa | ||
|
|
5056dd47ca | ||
|
|
97dde19577 | ||
|
|
7cbdebc4ed | ||
|
|
17795c6c4c | ||
|
|
6b25b78800 | ||
|
|
78b3f60dbd | ||
|
|
8f1ae5967e | ||
|
|
d82bfcecb1 | ||
|
|
5629c44547 | ||
|
|
a8bf14da84 | ||
|
|
a9f014e9df | ||
|
|
d76f2c0c3b | ||
|
|
f2a46b0661 | ||
|
|
0fa384c6f6 | ||
|
|
6d643ccd11 | ||
|
|
8b546facaf | ||
|
|
1f35ad12b3 | ||
|
|
3d4d30fd5a | ||
|
|
dd46fd36a3 | ||
|
|
85633eb615 | ||
|
|
2a3421a0da | ||
|
|
e38b8f6a20 | ||
|
|
646974b7d8 | ||
|
|
a86a1de849 | ||
|
|
be336cc1e4 | ||
|
|
8cecf2c7ea | ||
|
|
6af047c7f6 | ||
|
|
ac8338bb02 | ||
|
|
0188c541de | ||
|
|
97509ed1d7 | ||
|
|
432a5978b9 | ||
|
|
5f6a8083bf | ||
|
|
36d7ac31c2 | ||
|
|
aed3743630 | ||
|
|
28b1ea7c0d | ||
|
|
661c763b28 | ||
|
|
36a596aa9f | ||
|
|
c208a10619 | ||
|
|
e59e65be67 | ||
|
|
054e734e53 | ||
|
|
d007b9aba3 | ||
|
|
5d4868c036 | ||
|
|
8bf6206a3e | ||
|
|
1d3cfc4b01 | ||
|
|
1ff2ffa160 | ||
|
|
d07ba5f265 | ||
|
|
f789081bae | ||
|
|
388dc56ba5 | ||
|
|
6c7644268f | ||
|
|
c8d21fe7f0 | ||
|
|
00d846daf7 | ||
|
|
1b9860aa56 | ||
|
|
97d4d5effb | ||
|
|
12c6ef6d57 | ||
|
|
96277245dc | ||
|
|
eef24d452f | ||
|
|
c3baec7136 | ||
|
|
4bb86877e2 |
@@ -22,6 +22,8 @@ Use when:
|
||||
- Read dependency docs/source/types when the finding depends on external behavior.
|
||||
- Reject unrealistic edge cases, speculative risks, broad rewrites, and fixes that over-complicate the codebase.
|
||||
- Prefer small fixes at the right ownership boundary; no refactor unless it clearly improves the bug class.
|
||||
- When an accepted finding shows a bug class or repeated pattern, inspect the current PR scope for sibling instances before fixing.
|
||||
- Fix the scoped bug class at once when practical; stop at touched surfaces, owner boundaries, and clear follow-up territory.
|
||||
- Keep going until structured review returns no accepted/actionable findings.
|
||||
- If a review-triggered fix changes code, rerun focused tests and rerun the structured review helper.
|
||||
- For security-audit suppression changes, verify accepted findings remain auditable: suppressed findings stay in structured output, active output keeps an unsuppressible suppression notice, and aggregate findings cannot hide unrelated active risk.
|
||||
|
||||
156
.github/workflows/ci-check-arm-testbox.yml
vendored
Normal file
156
.github/workflows/ci-check-arm-testbox.yml
vendored
Normal file
@@ -0,0 +1,156 @@
|
||||
name: Blacksmith ARM Testbox
|
||||
on:
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
testbox_id:
|
||||
type: string
|
||||
description: "Testbox session ID"
|
||||
required: true
|
||||
pull_request:
|
||||
paths:
|
||||
- ".github/workflows/**"
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
PNPM_CONFIG_VERIFY_DEPS_BEFORE_RUN: "false"
|
||||
|
||||
jobs:
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
136
.github/workflows/ci-check-testbox.yml
vendored
136
.github/workflows/ci-check-testbox.yml
vendored
@@ -139,139 +139,3 @@ jobs:
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
check-arm:
|
||||
if: ${{ github.event_name != 'pull_request' || !github.event.pull_request.draft }}
|
||||
permissions:
|
||||
contents: read
|
||||
name: "check-arm"
|
||||
runs-on: blacksmith-16vcpu-ubuntu-2404-arm
|
||||
timeout-minutes: 120
|
||||
steps:
|
||||
- name: Begin Testbox
|
||||
uses: useblacksmith/begin-testbox@d0e04585c26905fdd92c94a09c159544c7ee1b67
|
||||
with:
|
||||
testbox_id: ${{ inputs.testbox_id }}
|
||||
- name: Verify ARM runner
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
runner_arch="$(uname -m)"
|
||||
echo "check-arm runner architecture: ${runner_arch}"
|
||||
case "$runner_arch" in
|
||||
aarch64 | arm64)
|
||||
;;
|
||||
*)
|
||||
echo "check-arm requires an ARM64 runner; got ${runner_arch}" >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
- name: Checkout
|
||||
shell: bash
|
||||
env:
|
||||
CHECKOUT_REPO: ${{ github.repository }}
|
||||
CHECKOUT_SHA: ${{ github.sha }}
|
||||
CHECKOUT_TOKEN: ${{ github.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
workdir="$GITHUB_WORKSPACE"
|
||||
if [[ -z "$CHECKOUT_TOKEN" ]]; then
|
||||
echo "checkout token is missing" >&2
|
||||
exit 1
|
||||
fi
|
||||
auth_header="$(printf 'x-access-token:%s' "$CHECKOUT_TOKEN" | base64 | tr -d '\n')"
|
||||
|
||||
reset_checkout_dir() {
|
||||
mkdir -p "$workdir"
|
||||
find "$workdir" -mindepth 1 -maxdepth 1 -exec rm -rf {} +
|
||||
}
|
||||
|
||||
checkout_attempt() {
|
||||
local attempt="$1"
|
||||
|
||||
reset_checkout_dir
|
||||
git init "$workdir" >/dev/null
|
||||
git config --global --add safe.directory "$workdir"
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
-c "http.extraheader=AUTHORIZATION: basic ${auth_header}" \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
git -C "$workdir" checkout --force --detach "$CHECKOUT_SHA" || return 1
|
||||
test -f "$workdir/.github/actions/setup-node-env/action.yml" || return 1
|
||||
echo "checkout attempt ${attempt}/5 succeeded"
|
||||
}
|
||||
|
||||
for attempt in 1 2 3 4 5; do
|
||||
if checkout_attempt "$attempt"; then
|
||||
exit 0
|
||||
fi
|
||||
echo "checkout attempt ${attempt}/5 failed"
|
||||
sleep $((attempt * 5))
|
||||
done
|
||||
|
||||
echo "checkout failed after 5 attempts" >&2
|
||||
exit 1
|
||||
- name: Setup Node environment
|
||||
uses: ./.github/actions/setup-node-env
|
||||
with:
|
||||
install-bun: "false"
|
||||
- name: Prepare Testbox shell
|
||||
shell: bash
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 30s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
|
||||
node_bin="$(dirname "$(node -p 'process.execPath')")"
|
||||
sudo ln -sf "$node_bin/node" /usr/local/bin/node
|
||||
sudo ln -sf "$node_bin/npm" /usr/local/bin/npm
|
||||
sudo ln -sf "$node_bin/npx" /usr/local/bin/npx
|
||||
sudo ln -sf "$node_bin/corepack" /usr/local/bin/corepack
|
||||
sudo tee /usr/local/bin/pnpm >/dev/null <<'PNPM'
|
||||
#!/usr/bin/env bash
|
||||
exec /usr/local/bin/corepack pnpm "$@"
|
||||
PNPM
|
||||
sudo chmod 0755 /usr/local/bin/pnpm
|
||||
|
||||
- name: Hydrate Testbox provider env helper
|
||||
shell: bash
|
||||
env:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
uses: useblacksmith/run-testbox@5ca05834db1d3813554d1dd109e5f2087a8d7cbc
|
||||
if: success()
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
15
.github/workflows/crabbox-hydrate.yml
vendored
15
.github/workflows/crabbox-hydrate.yml
vendored
@@ -120,6 +120,21 @@ jobs:
|
||||
append_pnpm_option_arg PNPM_CONFIG_MODULES_DIR modules-dir
|
||||
append_pnpm_option_arg PNPM_CONFIG_NETWORK_CONCURRENCY network-concurrency
|
||||
append_pnpm_option_arg PNPM_CONFIG_VIRTUAL_STORE_DIR virtual-store-dir
|
||||
reset_crabbox_pnpm_path() {
|
||||
local path="$1"
|
||||
if [ -z "$path" ]; then
|
||||
return
|
||||
fi
|
||||
case "$path" in
|
||||
/var/tmp/openclaw-pnpm-*) rm -rf "$path" ;;
|
||||
esac
|
||||
}
|
||||
reset_crabbox_pnpm_path "${PNPM_CONFIG_MODULES_DIR:-}"
|
||||
reset_crabbox_pnpm_path "${PNPM_CONFIG_STORE_DIR:-}"
|
||||
reset_crabbox_pnpm_path "${PNPM_CONFIG_VIRTUAL_STORE_DIR:-}"
|
||||
if [ -L node_modules ] && [ "$(readlink node_modules)" = "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
rm -f node_modules
|
||||
fi
|
||||
if [ -n "${PNPM_CONFIG_MODULES_DIR:-}" ]; then
|
||||
mkdir -p "$PNPM_CONFIG_MODULES_DIR"
|
||||
ln -sfn . "$PNPM_CONFIG_MODULES_DIR/node_modules"
|
||||
|
||||
7
.github/workflows/docker-release.yml
vendored
7
.github/workflows/docker-release.yml
vendored
@@ -4,6 +4,7 @@ on:
|
||||
push:
|
||||
tags:
|
||||
- "v*"
|
||||
- "!v*-alpha.*"
|
||||
paths-ignore:
|
||||
- "docs/**"
|
||||
- "**/*.md"
|
||||
@@ -38,7 +39,11 @@ jobs:
|
||||
RELEASE_TAG: ${{ inputs.tag }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-(alpha|beta)\.[1-9][0-9]*)?$ ]]; then
|
||||
if [[ "${RELEASE_TAG}" == *"-alpha."* ]]; then
|
||||
echo "Docker alpha image publishing is disabled."
|
||||
exit 1
|
||||
fi
|
||||
if [[ ! "${RELEASE_TAG}" =~ ^v[0-9]{4}\.[1-9][0-9]*\.[1-9][0-9]*(-beta\.[1-9][0-9]*)?$ ]]; then
|
||||
echo "Invalid release tag: ${RELEASE_TAG}"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
43
CHANGELOG.md
43
CHANGELOG.md
@@ -45,7 +45,49 @@ Docs: https://docs.openclaw.ai
|
||||
|
||||
### Fixes
|
||||
|
||||
- Discord: match the shipped `libopus-wasm` error shape so corrupt voice packets are treated as decode noise instead of crashing receive recovery.
|
||||
- Canvas: restore A2UI Google, X, and legacy Granola compatibility image assets in the bundled host payload.
|
||||
- Agents/providers: avoid loading owner plugin runtimes for explicitly configured custom provider models during OpenAI-compatible transport setup.
|
||||
- Tooling: fail Codex app-server protocol generation before invoking Cargo when local disk headroom is too low.
|
||||
- Release/CI/E2E: fail early when Crabbox sparse-sync full checkouts do not have enough local disk, with guidance for moving the sync root.
|
||||
- Release/CI/E2E: reset shared Crabbox pnpm hydrate state before installs so stale `/var/tmp` stores cannot leave `pnpm install` spinning after completion.
|
||||
- Release/CI/E2E: print heartbeat progress during centralized Docker builds while keeping successful build logs quiet.
|
||||
- Release/CI/E2E: avoid heartbeat-tail delays in Docker E2E log wrappers while reporting captured log bytes during long runs.
|
||||
- Release/CI/E2E: keep release user-journey logs and temporary plugin fixtures under per-run scratch roots so parallel runs cannot collide or leak artifacts.
|
||||
- Release/CI/E2E: bound release candidate GitHub API calls so stalled network requests cannot wedge workflow and artifact polling.
|
||||
- Release/CI/E2E: bound Discord smoke API calls in cross-OS release checks so host-side round trips cannot hang on stalled fetches.
|
||||
- Release/CI/E2E: bound RPC RTT gateway readiness probes so a half-open local HTTP response cannot stall cleanup past the readiness deadline.
|
||||
- Release/CI/E2E: stop RPC RTT gateway process groups so pnpm wrapper children cannot survive measurement cleanup.
|
||||
- Release/CI/E2E: fail the kitchen-sink RPC walk when command RSS sampling captures no process samples.
|
||||
- Release/CI/E2E: fail kitchen-sink RPC commands that exit cleanly only after their timeout expires.
|
||||
- Release/CI/E2E: force-stop memory/fd repro gateway children that survive listener cleanup.
|
||||
- Release/CI/E2E: remove fallback ClawHub skill-install home directories when proof runs fail.
|
||||
- Release/CI/E2E: let plugin lifecycle measurement wrappers exit promptly after external shutdown while preserving descendant cleanup.
|
||||
- Gateway: cancel client stop fallback termination when the socket closes normally during shutdown.
|
||||
- Installers: fail the PowerShell installer when interactive onboarding exits non-zero.
|
||||
- Scripts/UI: stop descendant processes from wrapped non-interactive commands when `run-with-env` receives shutdown signals.
|
||||
- Release/CI/E2E: write multi-node update Docker artifacts to unique per-run directories by default so parallel runs cannot overwrite evidence.
|
||||
- Release/CI/E2E: write package Telegram Docker artifacts to unique per-run directories by default so parallel live/RTT runs cannot overwrite evidence.
|
||||
- Release/CI/E2E: keep plugin lifecycle matrix resource artifacts under a unique per-run scratch root so parallel runs cannot overwrite tarballs or inspect output.
|
||||
- Release/CI/E2E: bound mock OpenAI readiness probes in web-search and Telegram RTT Docker smokes so stalled HTTP accepts cannot hang cleanup or fall through.
|
||||
- Tooling: cancel oversized pnpm audit advisory responses before failing so registry error paths do not leave response bodies open.
|
||||
- Release/CI/E2E: stop tracked gateway and mock service process groups so descendant helpers do not survive E2E cleanup.
|
||||
- Release/CI/E2E: exit Telegram credential proof wrappers promptly after forwarded shutdown signals while keeping the descendant force-kill guard armed.
|
||||
- Release/CI/E2E: reject oversized ClickClack fixture request bodies before release journey smokes can accumulate unbounded payloads.
|
||||
- Release/CI/E2E: reject oversized OpenAI image-auth mock request bodies before Docker proof runs can accumulate unbounded payloads.
|
||||
- Release/CI/E2E: require the Kitchen Sink RPC walk to prove every expected plugin tool is cataloged and effective before invoking tool fixtures.
|
||||
- Release/CI/E2E: stop tracked Docker build commands when centralized build wrappers receive shutdown signals.
|
||||
- Release/CI/E2E: cover MCP channel pairing reconnects by asserting the same temporary client state is reused across reconnects.
|
||||
- Release/CI/E2E: require QA channel baseline and reconnect scenarios to assert their scenario markers instead of accepting any outbound reply.
|
||||
- Release/CI/E2E: fail secret-provider proof runs when temporary state cleanup still fails after retries instead of hiding the cleanup error.
|
||||
- Release/CI/E2E: fail package-candidate ref proofs when temporary source worktree cleanup fails instead of leaving stale worktrees behind.
|
||||
- Release/CI/E2E: remove package tarball extract directories when tar extraction fails before validation can continue.
|
||||
- Release/CI/E2E: retry generated temp-state cleanup after removal failures and route plugin lifecycle measurement edits to their owner tests.
|
||||
- Release/CI/E2E: close parent gateway log handles after spawning RPC RTT probes so repeated measurements do not leak file descriptors.
|
||||
- Release/CI/E2E: fail RPC RTT probes when temporary state cleanup fails instead of hiding leftover scratch directories.
|
||||
- Release/CI/E2E: fail Kitchen Sink RPC walks when temporary state cleanup still fails after retries instead of silently preserving scratch roots.
|
||||
- Control UI: lazy-load the usage view so the initial app bundle stays below the chunk warning threshold.
|
||||
- Build: keep Baileys optional image backends external so source builds do not warn about missing `jimp` or `sharp`.
|
||||
- Build: render independent CLI startup metadata help snapshots concurrently to cut cold build-all metadata time.
|
||||
- Plugins: stop timed-out package-boundary prep steps by process group so descendant TypeScript/helper processes do not survive local check cleanup.
|
||||
- Control UI: serve static assets asynchronously after safe-open checks so large UI files do not block Gateway request handling.
|
||||
@@ -54,6 +96,7 @@ Docs: https://docs.openclaw.ai
|
||||
- Release/CI/E2E: keep temporary full-sync checkouts alive while slow Crabbox leases boot, so sparse worktree runs do not lose their sync source before file-list generation.
|
||||
- Release/CI/E2E: normalize inherited Linux `C.UTF-8` locale settings before raw AWS macOS Crabbox bootstrap commands, avoiding macOS locale warnings during package-manager hydration.
|
||||
- Release/CI/E2E: keep gateway watch regression checks from copying large static plugin assets inside the measured idle window.
|
||||
- Update: keep core updates nonblocking when a missing external plugin repair download stalls, while still blocking installed active plugin payload smoke failures.
|
||||
- Agents/providers: keep streaming tool-call argument parsing record-shaped when providers emit valid non-object JSON such as `null` or arrays.
|
||||
- Release/CI/E2E: reset incremental log readers when watched log files rotate without shrinking, so same-size replacements do not hide new readiness or RPC lines.
|
||||
- Talk: preserve explicit `null` payloads on controller-created turn and output-audio lifecycle events.
|
||||
|
||||
@@ -6,6 +6,7 @@ import ai.openclaw.app.SensitiveFeatureConfig
|
||||
import ai.openclaw.app.gateway.GatewayEndpoint
|
||||
import ai.openclaw.app.node.DeviceNotificationListenerService
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawErrorState
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
@@ -473,6 +474,14 @@ private fun GatewaySetupScreen(
|
||||
onClick = { advancedOpen = true },
|
||||
)
|
||||
}
|
||||
error?.let { message ->
|
||||
item {
|
||||
ClawErrorState(
|
||||
title = "Setup code issue",
|
||||
body = message,
|
||||
)
|
||||
}
|
||||
}
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Surface(
|
||||
@@ -505,9 +514,6 @@ private fun GatewaySetupScreen(
|
||||
}
|
||||
ClawTextField(value = token, onValueChange = onTokenChange, placeholder = "Token optional")
|
||||
ClawTextField(value = password, onValueChange = onPasswordChange, placeholder = "Password optional")
|
||||
error?.let {
|
||||
Text(text = it, style = ClawTheme.type.caption, color = ClawTheme.colors.warning)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,11 +18,15 @@ import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
@@ -78,9 +82,16 @@ internal fun ProvidersModelsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 13.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 13.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp), contentPadding = PaddingValues(bottom = 112.dp)) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
item {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Row(
|
||||
|
||||
@@ -13,11 +13,14 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
@@ -88,8 +91,15 @@ internal fun SessionsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(7.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
verticalArrangement = Arrangement.spacedBy(7.dp),
|
||||
contentPadding = PaddingValues(bottom = 4.dp),
|
||||
) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -133,11 +143,16 @@ internal fun SessionsScreen(
|
||||
|
||||
if (visibleSessions.isEmpty()) {
|
||||
item {
|
||||
ClawEmptyState(
|
||||
title = emptySessionTitle(filter),
|
||||
body = emptySessionBody(filter),
|
||||
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier.fillParentMaxHeight(0.56f).fillMaxWidth(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
ClawEmptyState(
|
||||
title = emptySessionTitle(filter),
|
||||
body = emptySessionBody(filter),
|
||||
action = { ClawPrimaryButton(text = "Start Chat", onClick = onOpenChat) },
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
items(visibleSessions, key = { it.key }) { session ->
|
||||
@@ -155,10 +170,6 @@ internal fun SessionsScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,11 +44,15 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
@@ -1028,8 +1032,11 @@ internal fun SettingsDetailFrame(
|
||||
onBack: () -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = ClawTheme.spacing.lg, top = 14.dp, end = ClawTheme.spacing.lg, bottom = 6.dp),
|
||||
contentWindowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal),
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
item {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
SettingsBackButton(onClick = onBack)
|
||||
@@ -1045,9 +1052,6 @@ internal fun SettingsDetailFrame(
|
||||
content()
|
||||
}
|
||||
}
|
||||
item {
|
||||
Spacer(modifier = Modifier.height(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,11 +9,14 @@ import ai.openclaw.app.HomeDestination
|
||||
import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.NodeRuntime
|
||||
import ai.openclaw.app.ui.chat.ChatScreen
|
||||
import ai.openclaw.app.ui.design.ClawBottomNav
|
||||
import ai.openclaw.app.ui.design.ClawDesignTheme
|
||||
import ai.openclaw.app.ui.design.ClawEmptyState
|
||||
import ai.openclaw.app.ui.design.ClawNavItem
|
||||
import ai.openclaw.app.ui.design.ClawPanel
|
||||
import ai.openclaw.app.ui.design.ClawPrimaryButton
|
||||
import ai.openclaw.app.ui.design.ClawScaffold
|
||||
import ai.openclaw.app.ui.design.ClawSecondaryButton
|
||||
import ai.openclaw.app.ui.design.ClawTheme
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
@@ -24,20 +27,26 @@ import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
import androidx.compose.foundation.layout.WindowInsetsSides
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.ime
|
||||
import androidx.compose.foundation.layout.only
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.safeDrawing
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.ExitToApp
|
||||
import androidx.compose.material.icons.automirrored.filled.KeyboardArrowRight
|
||||
import androidx.compose.material.icons.automirrored.filled.ScreenShare
|
||||
import androidx.compose.material.icons.filled.Cloud
|
||||
import androidx.compose.material.icons.filled.Home
|
||||
import androidx.compose.material.icons.filled.Lock
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.Notifications
|
||||
@@ -54,6 +63,7 @@ import androidx.compose.material.icons.outlined.Settings
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
@@ -69,23 +79,32 @@ import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
|
||||
private enum class Tab(
|
||||
internal enum class Tab(
|
||||
val key: String,
|
||||
val label: String,
|
||||
val icon: ImageVector,
|
||||
) {
|
||||
Overview(key = "overview", label = "Home"),
|
||||
Chat(key = "chat", label = "Chat"),
|
||||
Voice(key = "voice", label = "Voice"),
|
||||
Sessions(key = "sessions", label = "Sessions"),
|
||||
Settings(key = "settings", label = "Settings"),
|
||||
ProvidersModels(key = "providers-models", label = "Providers"),
|
||||
Overview(key = "overview", label = "Home", icon = Icons.Default.Home),
|
||||
Chat(key = "chat", label = "Chat", icon = Icons.Outlined.ChatBubbleOutline),
|
||||
Voice(key = "voice", label = "Voice", icon = Icons.Outlined.MicNone),
|
||||
Sessions(key = "sessions", label = "Sessions", icon = Icons.Outlined.AccessTime),
|
||||
Settings(key = "settings", label = "Settings", icon = Icons.Outlined.Settings),
|
||||
ProvidersModels(key = "providers-models", label = "Providers", icon = Icons.Outlined.Inventory2),
|
||||
}
|
||||
|
||||
private val shellNavTabs = listOf(Tab.Overview, Tab.Chat, Tab.Voice, Tab.Settings)
|
||||
|
||||
private val shellContentInsets: WindowInsets
|
||||
@Composable get() = WindowInsets.safeDrawing.only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal)
|
||||
|
||||
internal fun shellBottomNavVisible(keyboardVisible: Boolean, commandOpen: Boolean): Boolean = !keyboardVisible && !commandOpen
|
||||
|
||||
/** Main post-onboarding shell that owns top-level Android navigation state. */
|
||||
@Composable
|
||||
fun ShellScreen(
|
||||
@@ -131,117 +150,144 @@ fun ShellScreen(
|
||||
commandOpen = false
|
||||
}
|
||||
|
||||
Box(modifier = modifier.fillMaxSize()) {
|
||||
when (activeTab) {
|
||||
Tab.Overview ->
|
||||
OverviewScreen(
|
||||
viewModel = viewModel,
|
||||
onSelectTab = { activeTab = it },
|
||||
onOpenSettingsRoute = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = true
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
Tab.Chat ->
|
||||
ChatShellScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
onVoice = { activeTab = Tab.Voice },
|
||||
)
|
||||
Tab.Voice ->
|
||||
VoiceShellScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenGatewaySettings = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenVoiceSettings = {
|
||||
settingsRoute = SettingsRoute.Voice
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.ProvidersModels ->
|
||||
ProvidersModelsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
onAddProvider = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.Sessions ->
|
||||
SessionsScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenChat = { activeTab = Tab.Chat },
|
||||
)
|
||||
Tab.Settings ->
|
||||
SettingsShellScreen(
|
||||
viewModel = viewModel,
|
||||
route = settingsRoute,
|
||||
onRouteChange = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = false
|
||||
},
|
||||
onRouteBack = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
if (returnToOverviewFromSettings) {
|
||||
val density = LocalDensity.current
|
||||
val keyboardVisible = WindowInsets.ime.getBottom(density) > 0
|
||||
val showBottomNav = shellBottomNavVisible(keyboardVisible = keyboardVisible, commandOpen = commandOpen)
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier.fillMaxSize(),
|
||||
containerColor = ClawTheme.colors.canvas,
|
||||
contentWindowInsets = WindowInsets(0, 0, 0, 0),
|
||||
bottomBar = {
|
||||
if (showBottomNav) {
|
||||
ClawBottomNav(
|
||||
items = shellNavTabs.map { ClawNavItem(key = it.key, label = it.label, icon = it.icon) },
|
||||
selectedKey = if (activeTab in shellNavTabs) activeTab.key else Tab.Overview.key,
|
||||
onSelect = { key ->
|
||||
val next = shellNavTabs.firstOrNull { it.key == key } ?: Tab.Overview
|
||||
if (next == Tab.Settings) {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Overview
|
||||
}
|
||||
activeTab = next
|
||||
},
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
}
|
||||
}
|
||||
},
|
||||
) { shellPadding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(shellPadding)) {
|
||||
when (activeTab) {
|
||||
Tab.Overview ->
|
||||
OverviewScreen(
|
||||
viewModel = viewModel,
|
||||
onSelectTab = { activeTab = it },
|
||||
onOpenSettingsRoute = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = true
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
Tab.Chat ->
|
||||
ChatShellScreen(
|
||||
viewModel = viewModel,
|
||||
onVoice = { activeTab = Tab.Voice },
|
||||
onOpenSessions = { activeTab = Tab.Sessions },
|
||||
)
|
||||
Tab.Voice ->
|
||||
VoiceShellScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenGatewaySettings = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
onOpenVoiceSettings = {
|
||||
settingsRoute = SettingsRoute.Voice
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.ProvidersModels ->
|
||||
ProvidersModelsScreen(
|
||||
viewModel = viewModel,
|
||||
onBack = { activeTab = Tab.Overview },
|
||||
onAddProvider = {
|
||||
settingsRoute = SettingsRoute.Gateway
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
},
|
||||
)
|
||||
Tab.Sessions ->
|
||||
SessionsScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = { commandOpen = true },
|
||||
onOpenChat = { activeTab = Tab.Chat },
|
||||
)
|
||||
Tab.Settings ->
|
||||
SettingsShellScreen(
|
||||
viewModel = viewModel,
|
||||
route = settingsRoute,
|
||||
onRouteChange = {
|
||||
settingsRoute = it
|
||||
returnToOverviewFromSettings = false
|
||||
},
|
||||
onRouteBack = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
if (returnToOverviewFromSettings) {
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Overview
|
||||
}
|
||||
},
|
||||
onBackHome = { activeTab = Tab.Overview },
|
||||
onOpenCommand = { commandOpen = true },
|
||||
)
|
||||
}
|
||||
|
||||
if (commandOpen) {
|
||||
CommandPalette(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { commandOpen = false },
|
||||
onOpenChat = {
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenVoice = {
|
||||
activeTab = Tab.Voice
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSessions = {
|
||||
activeTab = Tab.Sessions
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenProviders = {
|
||||
activeTab = Tab.ProvidersModels
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSettings = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSession = { sessionKey ->
|
||||
viewModel.switchChatSession(sessionKey)
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
if (commandOpen) {
|
||||
CommandPalette(
|
||||
viewModel = viewModel,
|
||||
onDismiss = { commandOpen = false },
|
||||
onOpenChat = {
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenVoice = {
|
||||
activeTab = Tab.Voice
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSessions = {
|
||||
activeTab = Tab.Sessions
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenProviders = {
|
||||
activeTab = Tab.ProvidersModels
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSettings = {
|
||||
settingsRoute = SettingsRoute.Home
|
||||
returnToOverviewFromSettings = false
|
||||
activeTab = Tab.Settings
|
||||
commandOpen = false
|
||||
},
|
||||
onOpenSession = { sessionKey ->
|
||||
viewModel.switchChatSession(sessionKey)
|
||||
activeTab = Tab.Chat
|
||||
commandOpen = false
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
pendingTrust?.let { prompt ->
|
||||
// Gateway certificate trust is modal across the shell so navigation
|
||||
// cannot hide a changed TLS identity prompt.
|
||||
GatewayTrustDialog(
|
||||
prompt = prompt,
|
||||
onAccept = viewModel::acceptGatewayTrustPrompt,
|
||||
onDecline = viewModel::declineGatewayTrustPrompt,
|
||||
)
|
||||
pendingTrust?.let { prompt ->
|
||||
// Gateway certificate trust is modal across the shell so navigation
|
||||
// cannot hide a changed TLS identity prompt.
|
||||
GatewayTrustDialog(
|
||||
prompt = prompt,
|
||||
onAccept = viewModel::acceptGatewayTrustPrompt,
|
||||
onDecline = viewModel::declineGatewayTrustPrompt,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -289,33 +335,39 @@ private fun OverviewScreen(
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
val sessions by viewModel.chatSessions.collectAsState()
|
||||
val pendingRunCount by viewModel.pendingRunCount.collectAsState()
|
||||
val statusText by viewModel.statusText.collectAsState()
|
||||
val models by viewModel.modelCatalog.collectAsState()
|
||||
val providers by viewModel.modelAuthProviders.collectAsState()
|
||||
val agents by viewModel.gatewayAgents.collectAsState()
|
||||
val pendingToolCalls by viewModel.chatPendingToolCalls.collectAsState()
|
||||
val cronStatus by viewModel.cronStatus.collectAsState()
|
||||
val usageSummary by viewModel.usageSummary.collectAsState()
|
||||
val skillsSummary by viewModel.skillsSummary.collectAsState()
|
||||
val nodesDevicesSummary by viewModel.nodesDevicesSummary.collectAsState()
|
||||
val channelsSummary by viewModel.channelsSummary.collectAsState()
|
||||
val readyProviderCount = providers.count { modelProviderReady(it.status) }
|
||||
val attentionRows =
|
||||
homeAttentionRows(
|
||||
isConnected = isConnected,
|
||||
pendingApprovals = pendingToolCalls.size,
|
||||
channelsSummary = channelsSummary,
|
||||
nodesDevicesSummary = nodesDevicesSummary,
|
||||
readyProviderCount = readyProviderCount,
|
||||
)
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
viewModel.refreshChatSessions(limit = 20)
|
||||
viewModel.refreshModelCatalog()
|
||||
viewModel.refreshAgents()
|
||||
viewModel.refreshCronJobs()
|
||||
viewModel.refreshUsage()
|
||||
viewModel.refreshSkills()
|
||||
viewModel.refreshNodesDevices()
|
||||
viewModel.refreshChannels()
|
||||
}
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
Box(modifier = Modifier.fillMaxSize()) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(10.dp), contentPadding = PaddingValues(bottom = 104.dp)) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(12.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@@ -334,41 +386,20 @@ private fun OverviewScreen(
|
||||
}
|
||||
|
||||
item {
|
||||
SectionLabel(title = "MODULES")
|
||||
CompanionHeroPanel(
|
||||
statusText = gatewaySummary(statusText, isConnected),
|
||||
isConnected = isConnected,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onOpenChat = { onSelectTab(Tab.Chat) },
|
||||
onOpenVoice = { onSelectTab(Tab.Voice) },
|
||||
onOpenGateway = { onOpenSettingsRoute(SettingsRoute.Gateway) },
|
||||
)
|
||||
}
|
||||
|
||||
item {
|
||||
ModuleList(
|
||||
rows =
|
||||
listOf(
|
||||
ModuleRow("Chat", null, null, Icons.Outlined.ChatBubbleOutline, Tab.Chat),
|
||||
ModuleRow("Sessions", null, if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
|
||||
ModuleRow("Voice", null, if (isConnected) "Ready" else "Offline", Icons.Outlined.MicNone, Tab.Voice),
|
||||
ModuleRow(
|
||||
title = "Providers & Models",
|
||||
subtitle = null,
|
||||
metadata =
|
||||
when {
|
||||
!isConnected -> "Offline"
|
||||
readyProviderCount > 0 -> "$readyProviderCount ready"
|
||||
models.isNotEmpty() -> "${models.size} models"
|
||||
else -> "Setup"
|
||||
},
|
||||
icon = Icons.Outlined.Inventory2,
|
||||
tab = Tab.ProvidersModels,
|
||||
),
|
||||
ModuleRow("Channels", null, channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels),
|
||||
ModuleRow("Agents", null, if (agents.isEmpty()) "Load" else "${agents.size} ready", Icons.Default.Person, Tab.Settings, SettingsRoute.Agents),
|
||||
ModuleRow("Approvals", null, approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals),
|
||||
ModuleRow("Cron Jobs", null, cronJobsSummary(cronStatus.jobs), Icons.Outlined.AccessTime, Tab.Settings, SettingsRoute.CronJobs),
|
||||
ModuleRow("Skills", null, skillsSummaryText(skillsSummary.skills), Icons.Default.Settings, Tab.Settings, SettingsRoute.Skills),
|
||||
ModuleRow("Nodes & Devices", null, nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices),
|
||||
ModuleRow("Usage", null, usageSummaryText(usageSummary.providers.size), Icons.Default.Storage, Tab.Settings, SettingsRoute.Usage),
|
||||
ModuleRow("Settings", null, null, Icons.Outlined.Settings, Tab.Settings, SettingsRoute.Home),
|
||||
),
|
||||
onSelectTab = onSelectTab,
|
||||
onOpenSettingsRoute = onOpenSettingsRoute,
|
||||
)
|
||||
if (attentionRows.isNotEmpty()) {
|
||||
item {
|
||||
HomeAttentionPanel(rows = attentionRows, onSelectTab = onSelectTab, onOpenSettingsRoute = onOpenSettingsRoute)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
@@ -397,7 +428,7 @@ private fun OverviewScreen(
|
||||
item {
|
||||
RecentSessionList(
|
||||
rows =
|
||||
sessions.take(7).map { session ->
|
||||
sessions.take(5).map { session ->
|
||||
RecentSessionListItem(
|
||||
key = session.key,
|
||||
title = displaySessionTitle(session.displayName),
|
||||
@@ -412,8 +443,39 @@ private fun OverviewScreen(
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
item {
|
||||
SectionLabel(title = "Control center")
|
||||
}
|
||||
|
||||
item {
|
||||
ModuleList(
|
||||
rows =
|
||||
listOf(
|
||||
ModuleRow("Sessions", "Conversation history", if (sessions.isEmpty()) "Empty" else "${sessions.size} recent", Icons.Outlined.AccessTime, Tab.Sessions),
|
||||
ModuleRow(
|
||||
title = "Providers & Models",
|
||||
subtitle = "Model setup",
|
||||
metadata =
|
||||
when {
|
||||
!isConnected -> "Offline"
|
||||
readyProviderCount > 0 -> "$readyProviderCount ready"
|
||||
models.isNotEmpty() -> "${models.size} models"
|
||||
else -> "Setup"
|
||||
},
|
||||
icon = Icons.Outlined.Inventory2,
|
||||
tab = Tab.ProvidersModels,
|
||||
),
|
||||
ModuleRow("Channels", "Connected messengers", channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels),
|
||||
ModuleRow("Nodes & Devices", "Phone and node health", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices),
|
||||
ModuleRow("Approvals", "Tool decisions", approvalsSummary(pendingToolCalls.size), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals),
|
||||
ModuleRow("Settings", "More runtime controls", null, Icons.Outlined.Settings, Tab.Settings, SettingsRoute.Home),
|
||||
),
|
||||
onSelectTab = onSelectTab,
|
||||
onOpenSettingsRoute = onOpenSettingsRoute,
|
||||
)
|
||||
}
|
||||
}
|
||||
OverviewChatButton(onClick = { onSelectTab(Tab.Chat) }, modifier = Modifier.align(Alignment.BottomEnd).padding(bottom = 20.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -427,26 +489,109 @@ private data class ModuleRow(
|
||||
val settingsRoute: SettingsRoute? = null,
|
||||
)
|
||||
|
||||
/** Floating overview shortcut that keeps chat one tap away from module lists. */
|
||||
@Composable
|
||||
private fun OverviewChatButton(
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
private fun CompanionHeroPanel(
|
||||
statusText: String,
|
||||
isConnected: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onOpenChat: () -> Unit,
|
||||
onOpenVoice: () -> Unit,
|
||||
onOpenGateway: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = modifier.height(ClawTheme.spacing.touchTarget),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.button),
|
||||
color = ClawTheme.colors.primary,
|
||||
contentColor = ClawTheme.colors.primaryText,
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(18.dp))
|
||||
Text(text = "Chat", style = ClawTheme.type.label.copy(fontSize = 16.sp, lineHeight = 20.sp))
|
||||
ClawPanel(contentPadding = PaddingValues(16.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
Row(modifier = Modifier.fillMaxWidth(), verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Surface(
|
||||
modifier = Modifier.size(38.dp),
|
||||
shape = CircleShape,
|
||||
color = if (isConnected) ClawTheme.colors.successSoft else ClawTheme.colors.surfacePressed,
|
||||
border = BorderStroke(1.dp, if (isConnected) ClawTheme.colors.success else ClawTheme.colors.border),
|
||||
) {
|
||||
Box(contentAlignment = Alignment.Center) {
|
||||
Icon(imageVector = Icons.Outlined.ChatBubbleOutline, contentDescription = null, modifier = Modifier.size(19.dp), tint = if (isConnected) ClawTheme.colors.success else ClawTheme.colors.text)
|
||||
}
|
||||
}
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = if (pendingRunCount > 0) "OpenClaw is working" else "Ready when you are", style = ClawTheme.type.title.copy(fontSize = 20.sp, lineHeight = 24.sp), color = ClawTheme.colors.text)
|
||||
Text(text = statusText, style = ClawTheme.type.body, color = ClawTheme.colors.textMuted, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(9.dp)) {
|
||||
ClawPrimaryButton(text = "Start chat", icon = Icons.Outlined.ChatBubbleOutline, onClick = onOpenChat, modifier = Modifier.weight(1f))
|
||||
ClawSecondaryButton(text = "Voice", icon = Icons.Outlined.MicNone, onClick = onOpenVoice, modifier = Modifier.weight(1f))
|
||||
}
|
||||
if (!isConnected) {
|
||||
ClawSecondaryButton(text = "Reconnect gateway", icon = Icons.Default.Cloud, onClick = onOpenGateway, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal data class HomeAttentionRow(
|
||||
val title: String,
|
||||
val subtitle: String,
|
||||
val icon: ImageVector,
|
||||
val tab: Tab,
|
||||
val settingsRoute: SettingsRoute? = null,
|
||||
)
|
||||
|
||||
internal fun homeAttentionRows(
|
||||
isConnected: Boolean,
|
||||
pendingApprovals: Int,
|
||||
channelsSummary: GatewayChannelsSummary,
|
||||
nodesDevicesSummary: GatewayNodesDevicesSummary,
|
||||
readyProviderCount: Int,
|
||||
): List<HomeAttentionRow> =
|
||||
listOfNotNull(
|
||||
if (!isConnected) {
|
||||
HomeAttentionRow("Gateway", "Connect before chat, voice, and live status.", Icons.Default.Cloud, Tab.Settings, SettingsRoute.Gateway)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (pendingApprovals > 0) {
|
||||
HomeAttentionRow("Approvals", approvalsSummary(pendingApprovals), Icons.Default.Lock, Tab.Settings, SettingsRoute.Approvals)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (channelsSummary.channels.any { it.error != null }) {
|
||||
HomeAttentionRow("Channels", channelsSummaryText(channelsSummary), Icons.Default.Notifications, Tab.Settings, SettingsRoute.Channels)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (nodesDevicesSummary.pendingDevices.isNotEmpty()) {
|
||||
HomeAttentionRow("Nodes & Devices", nodesDevicesSummaryText(nodesDevicesSummary), Icons.Default.Cloud, Tab.Settings, SettingsRoute.NodesDevices)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
if (isConnected && readyProviderCount == 0) {
|
||||
HomeAttentionRow("Providers", "No ready providers", Icons.Outlined.Inventory2, Tab.ProvidersModels)
|
||||
} else {
|
||||
null
|
||||
},
|
||||
)
|
||||
|
||||
@Composable
|
||||
private fun HomeAttentionPanel(
|
||||
rows: List<HomeAttentionRow>,
|
||||
onSelectTab: (Tab) -> Unit,
|
||||
onOpenSettingsRoute: (SettingsRoute) -> Unit,
|
||||
) {
|
||||
ClawPanel(contentPadding = PaddingValues(horizontal = 14.dp, vertical = 8.dp)) {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
Text(text = "Needs attention", style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.warning)
|
||||
rows.forEach { row ->
|
||||
ModuleListRow(
|
||||
row = ModuleRow(row.title, row.subtitle, null, row.icon, row.tab, row.settingsRoute),
|
||||
onClick = {
|
||||
val route = row.settingsRoute
|
||||
if (route == null) {
|
||||
onSelectTab(row.tab)
|
||||
} else {
|
||||
onOpenSettingsRoute(route)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -527,14 +672,18 @@ private fun ModuleListRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
Icon(imageVector = row.icon, contentDescription = null, modifier = Modifier.size(20.dp), tint = ClawTheme.colors.text)
|
||||
Text(
|
||||
text = row.title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
modifier = Modifier.weight(1f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Column(modifier = Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(1.dp)) {
|
||||
Text(
|
||||
text = row.title,
|
||||
style = ClawTheme.type.body,
|
||||
color = ClawTheme.colors.text,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
row.subtitle?.let {
|
||||
Text(text = it, style = ClawTheme.type.caption.copy(fontSize = 12.5.sp, lineHeight = 16.sp), color = ClawTheme.colors.textSubtle, maxLines = 1, overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
row.metadata?.let {
|
||||
Row(verticalAlignment = Alignment.CenterVertically, horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
Box(modifier = Modifier.size(4.5.dp).clip(CircleShape).background(statusDotColor(it)))
|
||||
@@ -638,11 +787,18 @@ private fun RecentSessionRowContent(
|
||||
@Composable
|
||||
private fun ChatShellScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp)) {
|
||||
ChatScreen(viewModel = viewModel, onBack = onBack, onVoice = onVoice)
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
ChatScreen(
|
||||
viewModel = viewModel,
|
||||
onVoice = onVoice,
|
||||
onOpenSessions = onOpenSessions,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -653,7 +809,10 @@ private fun VoiceShellScreen(
|
||||
onOpenGatewaySettings: () -> Unit,
|
||||
onOpenVoiceSettings: () -> Unit,
|
||||
) {
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 8.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 0.dp, top = 8.dp, end = 0.dp, bottom = 0.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
VoiceScreen(
|
||||
viewModel = viewModel,
|
||||
onOpenCommand = onOpenCommand,
|
||||
@@ -669,6 +828,7 @@ private fun SettingsShellScreen(
|
||||
route: SettingsRoute,
|
||||
onRouteChange: (SettingsRoute) -> Unit,
|
||||
onRouteBack: () -> Unit,
|
||||
onBackHome: () -> Unit,
|
||||
onOpenCommand: () -> Unit,
|
||||
) {
|
||||
val displayName by viewModel.displayName.collectAsState()
|
||||
@@ -707,14 +867,18 @@ private fun SettingsShellScreen(
|
||||
return
|
||||
}
|
||||
|
||||
ClawScaffold(contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 20.dp)) {
|
||||
LazyColumn(verticalArrangement = Arrangement.spacedBy(13.dp)) {
|
||||
ClawScaffold(
|
||||
contentPadding = PaddingValues(start = 20.dp, top = 14.dp, end = 20.dp, bottom = 6.dp),
|
||||
contentWindowInsets = shellContentInsets,
|
||||
) {
|
||||
LazyColumn(modifier = Modifier.fillMaxSize(), verticalArrangement = Arrangement.spacedBy(13.dp), contentPadding = PaddingValues(bottom = 4.dp)) {
|
||||
item {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(9.dp),
|
||||
) {
|
||||
PlainIconButton(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back to home", onClick = onBackHome)
|
||||
Text(text = "Settings", style = ClawTheme.type.title.copy(fontSize = 16.sp, lineHeight = 20.sp), color = ClawTheme.colors.text, modifier = Modifier.weight(1f))
|
||||
SettingsSearchButton(onClick = onOpenCommand)
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import ai.openclaw.app.MainViewModel
|
||||
import ai.openclaw.app.chat.ChatMessage
|
||||
import ai.openclaw.app.chat.ChatMessageContent
|
||||
import ai.openclaw.app.chat.ChatPendingToolCall
|
||||
import ai.openclaw.app.chat.ChatSessionEntry
|
||||
import ai.openclaw.app.chat.OutgoingAttachment
|
||||
import ai.openclaw.app.ui.design.ClawListItem
|
||||
import ai.openclaw.app.ui.design.ClawLoadingState
|
||||
@@ -37,11 +38,11 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.AttachFile
|
||||
import androidx.compose.material.icons.filled.Close
|
||||
import androidx.compose.material.icons.filled.Mic
|
||||
import androidx.compose.material.icons.filled.MoreHoriz
|
||||
import androidx.compose.material.icons.filled.Refresh
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
@@ -78,8 +79,8 @@ import java.util.Locale
|
||||
@Composable
|
||||
fun ChatScreen(
|
||||
viewModel: MainViewModel,
|
||||
onBack: () -> Unit,
|
||||
onVoice: () -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
) {
|
||||
val messages by viewModel.chatMessages.collectAsState()
|
||||
val historyLoading by viewModel.chatHistoryLoading.collectAsState()
|
||||
@@ -158,13 +159,23 @@ fun ChatScreen(
|
||||
thinkingLevel = thinkingLevel,
|
||||
healthOk = healthOk,
|
||||
pendingRunCount = pendingRunCount,
|
||||
onBack = onBack,
|
||||
onMore = {
|
||||
viewModel.refreshChat()
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
},
|
||||
)
|
||||
|
||||
ChatSessionSwitcher(
|
||||
sessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
onSelectSession = { key ->
|
||||
viewModel.switchChatSession(key)
|
||||
viewModel.refreshChatSessions(limit = 100)
|
||||
},
|
||||
onOpenSessions = onOpenSessions,
|
||||
)
|
||||
|
||||
errorText?.takeIf { it.isNotBlank() }?.let { error ->
|
||||
ChatNotice(title = "Chat needs attention", body = userFacingChatError(error))
|
||||
}
|
||||
@@ -214,13 +225,88 @@ fun ChatScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatSessionSwitcher(
|
||||
sessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
onSelectSession: (String) -> Unit,
|
||||
onOpenSessions: () -> Unit,
|
||||
) {
|
||||
val choices =
|
||||
remember(sessionKey, sessions, mainSessionKey) {
|
||||
resolveCompactSessionChoices(
|
||||
currentSessionKey = sessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
)
|
||||
}
|
||||
if (choices.size <= 1 && sessions.size <= 1) return
|
||||
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().horizontalScroll(rememberScrollState()),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
choices.forEach { entry ->
|
||||
ChatSessionChip(
|
||||
text = chatSessionChipText(entry = entry, mainSessionKey = mainSessionKey),
|
||||
active = isActiveSessionChoice(entry.key, sessionKey, mainSessionKey),
|
||||
onClick = { onSelectSession(entry.key) },
|
||||
)
|
||||
}
|
||||
if (sessions.size > choices.size) {
|
||||
Surface(
|
||||
onClick = onOpenSessions,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = ClawTheme.colors.canvas,
|
||||
contentColor = ClawTheme.colors.textMuted,
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
) {
|
||||
Row(
|
||||
modifier = Modifier.padding(horizontal = 10.dp, vertical = 7.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(5.dp),
|
||||
) {
|
||||
Icon(imageVector = Icons.Default.MoreHoriz, contentDescription = null, modifier = Modifier.size(16.dp))
|
||||
Text(text = "All", style = ClawTheme.type.caption, maxLines = 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatSessionChip(
|
||||
text: String,
|
||||
active: Boolean,
|
||||
onClick: () -> Unit,
|
||||
) {
|
||||
Surface(
|
||||
onClick = onClick,
|
||||
modifier = Modifier.heightIn(min = 36.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.pill),
|
||||
color = if (active) ClawTheme.colors.primary else ClawTheme.colors.surfaceRaised,
|
||||
contentColor = if (active) ClawTheme.colors.primaryText else ClawTheme.colors.text,
|
||||
border = BorderStroke(1.dp, if (active) ClawTheme.colors.primary else ClawTheme.colors.border),
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
modifier = Modifier.padding(horizontal = 11.dp, vertical = 7.dp),
|
||||
style = ClawTheme.type.caption,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ChatHeader(
|
||||
sessionTitle: String,
|
||||
thinkingLevel: String,
|
||||
healthOk: Boolean,
|
||||
pendingRunCount: Int,
|
||||
onBack: () -> Unit,
|
||||
onMore: () -> Unit,
|
||||
) {
|
||||
Row(
|
||||
@@ -228,7 +314,7 @@ private fun ChatHeader(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
HeaderIcon(icon = Icons.AutoMirrored.Filled.ArrowBack, contentDescription = "Back", onClick = onBack)
|
||||
Box(modifier = Modifier.size(ClawTheme.spacing.touchTarget))
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
@@ -786,13 +872,33 @@ private fun AttachmentChip(
|
||||
|
||||
private fun currentSessionTitle(
|
||||
sessionKey: String,
|
||||
sessions: List<ai.openclaw.app.chat.ChatSessionEntry>,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
): String {
|
||||
val entry = sessions.firstOrNull { it.key == sessionKey }
|
||||
val name = entry?.displayName?.takeIf { it.isNotBlank() } ?: return "New chat"
|
||||
return friendlySessionName(name)
|
||||
}
|
||||
|
||||
private fun chatSessionChipText(
|
||||
entry: ChatSessionEntry,
|
||||
mainSessionKey: String,
|
||||
): String {
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
if (entry.key == mainKey || (entry.key == "main" && mainKey == "main")) return "Main"
|
||||
val name = entry.displayName?.takeIf { it.isNotBlank() } ?: entry.key.takeIf { entry.updatedAtMs != null } ?: "Current"
|
||||
return friendlySessionName(name)
|
||||
}
|
||||
|
||||
private fun isActiveSessionChoice(
|
||||
choiceKey: String,
|
||||
sessionKey: String,
|
||||
mainSessionKey: String,
|
||||
): Boolean {
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = sessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
return choiceKey == current
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SendButton(
|
||||
enabled: Boolean,
|
||||
|
||||
@@ -4,22 +4,9 @@ import ai.openclaw.app.chat.ChatSessionEntry
|
||||
|
||||
private const val RECENT_WINDOW_MS = 24 * 60 * 60 * 1000L
|
||||
|
||||
/**
|
||||
* Derive a human-friendly label from a raw session key.
|
||||
* Examples:
|
||||
* "telegram:g-agent-main-main" -> "Main"
|
||||
* "agent:main:main" -> "Main"
|
||||
* "discord:g-server-channel" -> "Server Channel"
|
||||
* "my-custom-session" -> "My Custom Session"
|
||||
*/
|
||||
fun friendlySessionName(key: String): String {
|
||||
// Strip common prefixes like "telegram:", "agent:", "discord:" etc.
|
||||
val stripped = key.substringAfterLast(":")
|
||||
|
||||
// Remove leading "g-" prefix (gateway artifact)
|
||||
val cleaned = if (stripped.startsWith("g-")) stripped.removePrefix("g-") else stripped
|
||||
|
||||
// Split on hyphens/underscores, title-case each word, collapse "main main" -> "Main"
|
||||
val words =
|
||||
cleaned
|
||||
.split('-', '_')
|
||||
@@ -78,3 +65,29 @@ fun resolveSessionChoices(
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
fun resolveCompactSessionChoices(
|
||||
currentSessionKey: String,
|
||||
sessions: List<ChatSessionEntry>,
|
||||
mainSessionKey: String,
|
||||
nowMs: Long = System.currentTimeMillis(),
|
||||
maxOptions: Int = 5,
|
||||
): List<ChatSessionEntry> {
|
||||
val allChoices =
|
||||
resolveSessionChoices(
|
||||
currentSessionKey = currentSessionKey,
|
||||
sessions = sessions,
|
||||
mainSessionKey = mainSessionKey,
|
||||
nowMs = nowMs,
|
||||
)
|
||||
val mainKey = mainSessionKey.trim().ifEmpty { "main" }
|
||||
val current = currentSessionKey.trim().let { if (it == "main" && mainKey != "main") mainKey else it }
|
||||
val pinnedRank = listOf(mainKey, current).filter { it.isNotBlank() }.distinct().withIndex().associate { it.value to it.index }
|
||||
val unpinnedRank = pinnedRank.size
|
||||
|
||||
return allChoices
|
||||
.withIndex()
|
||||
.sortedWith(compareBy({ pinnedRank[it.value.key] ?: unpinnedRank }, { it.index }))
|
||||
.take(maxOptions)
|
||||
.map { it.value }
|
||||
}
|
||||
|
||||
@@ -61,6 +61,7 @@ internal enum class ClawStatus {
|
||||
internal fun ClawScaffold(
|
||||
modifier: Modifier = Modifier,
|
||||
contentPadding: PaddingValues = PaddingValues(horizontal = ClawTheme.spacing.lg, vertical = ClawTheme.spacing.lg),
|
||||
contentWindowInsets: WindowInsets = WindowInsets.safeDrawing,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
Box(
|
||||
@@ -68,7 +69,7 @@ internal fun ClawScaffold(
|
||||
modifier
|
||||
.fillMaxSize()
|
||||
.background(ClawTheme.colors.canvas)
|
||||
.windowInsetsPadding(WindowInsets.safeDrawing)
|
||||
.windowInsetsPadding(contentWindowInsets)
|
||||
.padding(contentPadding),
|
||||
) {
|
||||
content()
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
package ai.openclaw.app.ui.design
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@@ -91,27 +92,29 @@ internal fun ClawBottomNav(
|
||||
) {
|
||||
val safeInsets = WindowInsets.navigationBars.only(androidx.compose.foundation.layout.WindowInsetsSides.Bottom)
|
||||
|
||||
Surface(
|
||||
modifier = modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
) {
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
Box(modifier = modifier.fillMaxWidth().background(ClawTheme.colors.canvas)) {
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = ClawTheme.colors.surface.copy(alpha = 0.96f),
|
||||
border = BorderStroke(1.dp, ClawTheme.colors.border),
|
||||
shape = RoundedCornerShape(topStart = ClawTheme.radii.sheet, topEnd = ClawTheme.radii.sheet),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
ClawBottomNavItem(
|
||||
item = item,
|
||||
selected = item.key == selectedKey,
|
||||
onClick = { onSelect(item.key) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
Row(
|
||||
modifier =
|
||||
Modifier
|
||||
.windowInsetsPadding(safeInsets)
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
items.forEach { item ->
|
||||
ClawBottomNavItem(
|
||||
item = item,
|
||||
selected = item.key == selectedKey,
|
||||
onClick = { onSelect(item.key) },
|
||||
modifier = Modifier.weight(1f),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -129,7 +132,7 @@ private fun ClawBottomNavItem(
|
||||
modifier = modifier.heightIn(min = 48.dp),
|
||||
shape = RoundedCornerShape(ClawTheme.radii.control),
|
||||
color = if (selected) ClawTheme.colors.primary else Color.Transparent,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textSubtle,
|
||||
contentColor = if (selected) ClawTheme.colors.primaryText else ClawTheme.colors.textMuted,
|
||||
) {
|
||||
Column(
|
||||
modifier = Modifier.padding(horizontal = 5.dp, vertical = 6.dp),
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
package ai.openclaw.app.ui
|
||||
|
||||
import ai.openclaw.app.GatewayChannelSummary
|
||||
import ai.openclaw.app.GatewayChannelsSummary
|
||||
import ai.openclaw.app.GatewayNodesDevicesSummary
|
||||
import ai.openclaw.app.GatewayPendingDeviceSummary
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertFalse
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Test
|
||||
|
||||
class ShellScreenLogicTest {
|
||||
@Test
|
||||
fun bottomNavHidesForKeyboardAndCommandPalette() {
|
||||
assertTrue(shellBottomNavVisible(keyboardVisible = false, commandOpen = false))
|
||||
assertFalse(shellBottomNavVisible(keyboardVisible = true, commandOpen = false))
|
||||
assertFalse(shellBottomNavVisible(keyboardVisible = false, commandOpen = true))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceGatewayWhenDisconnected() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = false,
|
||||
pendingApprovals = 0,
|
||||
channelsSummary = emptyChannels(),
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
readyProviderCount = 0,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Gateway"), rows.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsSurfaceOnlyActionableConnectedIssues() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = true,
|
||||
pendingApprovals = 2,
|
||||
channelsSummary =
|
||||
GatewayChannelsSummary(
|
||||
channels =
|
||||
listOf(
|
||||
GatewayChannelSummary(
|
||||
id = "telegram",
|
||||
label = "Telegram",
|
||||
accountCount = 1,
|
||||
enabled = true,
|
||||
configured = true,
|
||||
linked = true,
|
||||
running = false,
|
||||
connected = false,
|
||||
error = "offline",
|
||||
),
|
||||
),
|
||||
),
|
||||
nodesDevicesSummary =
|
||||
GatewayNodesDevicesSummary(
|
||||
nodes = emptyList(),
|
||||
pendingDevices =
|
||||
listOf(
|
||||
GatewayPendingDeviceSummary(
|
||||
requestId = "request-1",
|
||||
deviceId = "device-1",
|
||||
displayName = "Phone",
|
||||
remoteIp = null,
|
||||
roles = emptyList(),
|
||||
scopes = emptyList(),
|
||||
requestedAtMs = null,
|
||||
repair = false,
|
||||
),
|
||||
),
|
||||
pairedDevices = emptyList(),
|
||||
),
|
||||
readyProviderCount = 0,
|
||||
)
|
||||
|
||||
assertEquals(listOf("Approvals", "Channels", "Nodes & Devices", "Providers"), rows.map { it.title })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun homeAttentionRowsStayQuietWhenConnectedAndHealthy() {
|
||||
val rows =
|
||||
homeAttentionRows(
|
||||
isConnected = true,
|
||||
pendingApprovals = 0,
|
||||
channelsSummary = emptyChannels(),
|
||||
nodesDevicesSummary = emptyNodesDevices(),
|
||||
readyProviderCount = 1,
|
||||
)
|
||||
|
||||
assertEquals(emptyList<String>(), rows.map { it.title })
|
||||
}
|
||||
|
||||
private fun emptyChannels(): GatewayChannelsSummary = GatewayChannelsSummary(channels = emptyList())
|
||||
|
||||
private fun emptyNodesDevices(): GatewayNodesDevicesSummary = GatewayNodesDevicesSummary(nodes = emptyList(), pendingDevices = emptyList(), pairedDevices = emptyList())
|
||||
}
|
||||
@@ -32,4 +32,29 @@ class SessionFiltersTest {
|
||||
val result = resolveSessionChoices("custom", sessions, mainSessionKey = "main", nowMs = now).map { it.key }
|
||||
assertEquals(listOf("main", "custom"), result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun compactChoicesKeepMainAndCurrentWhileCappingRecentSessions() {
|
||||
val now = 1_700_000_000_000L
|
||||
val sessions =
|
||||
listOf(
|
||||
ChatSessionEntry(key = "recent-1", updatedAtMs = now - 1),
|
||||
ChatSessionEntry(key = "recent-2", updatedAtMs = now - 2),
|
||||
ChatSessionEntry(key = "recent-3", updatedAtMs = now - 3),
|
||||
ChatSessionEntry(key = "recent-4", updatedAtMs = now - 4),
|
||||
ChatSessionEntry(key = "main", updatedAtMs = now - 5),
|
||||
ChatSessionEntry(key = "active-old", updatedAtMs = now - 30 * 60 * 60 * 1000L),
|
||||
)
|
||||
|
||||
val result =
|
||||
resolveCompactSessionChoices(
|
||||
currentSessionKey = "active-old",
|
||||
sessions = sessions,
|
||||
mainSessionKey = "main",
|
||||
nowMs = now,
|
||||
maxOptions = 4,
|
||||
).map { it.key }
|
||||
|
||||
assertEquals(listOf("main", "active-old", "recent-1", "recent-2"), result)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6896,6 +6896,20 @@ public struct ChatHistoryParams: Codable, Sendable {
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMetadataParams: Codable, Sendable {
|
||||
public let agentid: String?
|
||||
|
||||
public init(
|
||||
agentid: String? = nil)
|
||||
{
|
||||
self.agentid = agentid
|
||||
}
|
||||
|
||||
private enum CodingKeys: String, CodingKey {
|
||||
case agentid = "agentId"
|
||||
}
|
||||
}
|
||||
|
||||
public struct ChatMessageGetParams: Codable, Sendable {
|
||||
public let sessionkey: String
|
||||
public let agentid: String?
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
f3e0379cbe0e584a8c9658253d4a808356fe80fb5ec775bbee9e968e8d815380 plugin-sdk-api-baseline.json
|
||||
601b55acafbd1e00b850c9b0c15d587029050906960071d448d37538b223e226 plugin-sdk-api-baseline.jsonl
|
||||
a9501e226bb26befb02072cf5e60c3dc124cbd5dc0b16eb281789d0843f72f71 plugin-sdk-api-baseline.json
|
||||
b106090dc12bf7e46beac4ed160f0cff0ef8039291f24172b693e8d8b752d571 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -122,6 +122,33 @@ This fires ~5–6 times per month instead of 0–1 times per month. OpenClaw use
|
||||
</Accordion>
|
||||
</AccordionGroup>
|
||||
|
||||
### Command payloads
|
||||
|
||||
Use command payloads for deterministic scripts that should run inside the Gateway scheduler without starting a model-backed isolated agent turn. Command jobs execute on the Gateway host, capture stdout/stderr, record the run in cron history, and reuse the same `announce`, `webhook`, and `none` delivery modes as isolated jobs.
|
||||
|
||||
<Note>
|
||||
Command cron is an operator-admin Gateway automation surface, not an agent
|
||||
`tools.exec` call. Creating, updating, removing, or manually running cron jobs
|
||||
requires `operator.admin`; scheduled command runs later execute inside the
|
||||
Gateway process as that admin-authored automation. Agent exec policy such as
|
||||
`tools.exec.mode`, approval prompts, and per-agent tool allowlists governs
|
||||
model-visible exec tools, not command cron payloads.
|
||||
</Note>
|
||||
|
||||
```bash
|
||||
openclaw cron create "*/15 * * * *" \
|
||||
--name "Queue depth probe" \
|
||||
--command "scripts/check-queue.sh" \
|
||||
--command-cwd "/srv/app" \
|
||||
--announce \
|
||||
--channel telegram \
|
||||
--to "-1001234567890"
|
||||
```
|
||||
|
||||
`--command <shell>` stores `argv: ["sh", "-lc", <shell>]`. Use `--command-argv '["node","scripts/report.mjs"]'` when you want exact argv execution without shell parsing. Optional `--command-env KEY=VALUE`, `--command-input`, `--timeout-seconds`, `--no-output-timeout-seconds`, and `--output-max-bytes` fields control the process environment, stdin, and output bounds.
|
||||
|
||||
If stdout is non-empty, that text is the delivered result. If stdout is empty and stderr is non-empty, stderr is delivered. If both streams are present, cron delivers a small `stdout:` / `stderr:` block. A zero exit code records the run as `ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and can trigger failure alerts. A command that prints only `NO_REPLY` uses the normal cron silent-token suppression and posts nothing back to chat.
|
||||
|
||||
### Payload options for isolated jobs
|
||||
|
||||
<ParamField path="--message" type="string" required>
|
||||
@@ -246,6 +273,17 @@ Failure notifications follow a separate destination path:
|
||||
--webhook "https://example.invalid/openclaw/cron"
|
||||
```
|
||||
</Tab>
|
||||
<Tab title="Command output">
|
||||
```bash
|
||||
openclaw cron create "*/15 * * * *" \
|
||||
--name "Queue depth probe" \
|
||||
--command "scripts/check-queue.sh" \
|
||||
--command-cwd "/srv/app" \
|
||||
--announce \
|
||||
--channel telegram \
|
||||
--to "-1001234567890"
|
||||
```
|
||||
</Tab>
|
||||
</Tabs>
|
||||
|
||||
## Webhooks
|
||||
|
||||
@@ -319,6 +319,7 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
|
||||
- `progress` keeps one editable status draft for tool progress, clears it at completion, and sends the final answer as a normal message
|
||||
- `streaming.preview.toolProgress` controls whether tool/progress updates reuse the same edited preview message (default: `true` when preview streaming is active)
|
||||
- `streaming.preview.commandText` controls command/exec detail inside those tool-progress lines: `raw` (default, preserves released behavior) or `status` (tool label only)
|
||||
- `streaming.progress.commentary` (default: `false`) opts into assistant commentary/preamble text in the temporary progress draft
|
||||
- legacy `channels.telegram.streamMode` and boolean `streaming` values are detected; run `openclaw doctor --fix` to migrate them to `channels.telegram.streaming.mode`
|
||||
|
||||
Tool-progress preview updates are the short status lines shown while tools run, for example command execution, file reads, planning updates, patch summaries, or Codex preamble/commentary text in Codex app-server mode. Telegram keeps these enabled by default to match released OpenClaw behavior from `v2026.4.22` and later.
|
||||
|
||||
@@ -34,6 +34,27 @@ openclaw cron create "0 18 * * 1-5" \
|
||||
--webhook "https://example.invalid/openclaw/cron"
|
||||
```
|
||||
|
||||
Use `--command` for deterministic shell-style jobs that should run inside OpenClaw cron without starting an isolated agent/model run:
|
||||
|
||||
<Note>
|
||||
Command cron jobs are admin-authored Gateway automation. Creating, editing,
|
||||
removing, or manually running them requires `operator.admin`; the scheduled run
|
||||
later executes in the Gateway process, not as an agent `tools.exec` tool call.
|
||||
`tools.exec.*` and exec approvals still govern model-visible exec tools.
|
||||
</Note>
|
||||
|
||||
```bash
|
||||
openclaw cron create "*/15 * * * *" \
|
||||
--name "Queue depth probe" \
|
||||
--command "scripts/check-queue.sh" \
|
||||
--command-cwd "/srv/app" \
|
||||
--announce \
|
||||
--channel telegram \
|
||||
--to "-1001234567890"
|
||||
```
|
||||
|
||||
`--command <shell>` stores `argv: ["sh", "-lc", <shell>]`. Use `--command-argv '["node","scripts/report.mjs"]'` for exact argv execution. Command jobs capture stdout/stderr, record normal cron history, and route output through the same `announce`, `webhook`, or `none` delivery modes as isolated jobs. A command that prints only `NO_REPLY` is suppressed.
|
||||
|
||||
## Sessions
|
||||
|
||||
`--session` accepts `main`, `isolated`, `current`, or `session:<id>`.
|
||||
@@ -92,6 +113,10 @@ Note: isolated cron runs treat run-level agent failures as job errors even when
|
||||
no reply payload is produced, so model/provider failures still increment error
|
||||
counters and trigger failure notifications.
|
||||
|
||||
Command cron jobs do not start an isolated agent turn. A zero exit code records
|
||||
`ok`; non-zero exit, signal, timeout, or no-output timeout records `error` and
|
||||
can trigger the same failure notification path.
|
||||
|
||||
If an isolated run times out before the first model request, `openclaw cron show`
|
||||
and `openclaw cron runs` include a phase-specific error such as
|
||||
`setup timed out before runner start` or
|
||||
@@ -252,6 +277,21 @@ openclaw cron create "0 7 * * *" \
|
||||
|
||||
`--light-context` applies to isolated agent-turn jobs only. For cron runs, lightweight mode keeps bootstrap context empty instead of injecting the full workspace bootstrap set.
|
||||
|
||||
Create a command job with exact argv, cwd, env, stdin, and output limits:
|
||||
|
||||
```bash
|
||||
openclaw cron create "*/30 * * * *" \
|
||||
--name "Position export" \
|
||||
--command-argv '["node","scripts/export-position.mjs"]' \
|
||||
--command-cwd "/srv/app" \
|
||||
--command-env "NODE_ENV=production" \
|
||||
--command-input '{"mode":"summary"}' \
|
||||
--timeout-seconds 120 \
|
||||
--no-output-timeout-seconds 30 \
|
||||
--output-max-bytes 65536 \
|
||||
--webhook "https://example.invalid/openclaw/cron"
|
||||
```
|
||||
|
||||
## Common admin commands
|
||||
|
||||
Manual run and inspection:
|
||||
|
||||
@@ -19,7 +19,7 @@ instead of creating a separate health gate.
|
||||
|
||||
Policy currently manages configured channels, MCP servers, model providers,
|
||||
network SSRF posture, ingress/channel access posture, Gateway exposure posture, agent workspace posture,
|
||||
OpenClaw config secret provider/auth profile posture, and governed tool
|
||||
data-handling posture, OpenClaw config secret provider/auth profile posture, and governed tool
|
||||
declarations. For example, IT or a workspace operator can record that Telegram
|
||||
is not an approved channel provider, restrict MCP servers and model refs to
|
||||
approved entries, require private-network fetch/browser access to remain
|
||||
@@ -28,7 +28,9 @@ to stay within reviewed bounds, require Gateway bind/auth/HTTP exposure to stay
|
||||
bounds, require agent workspace access and tool denies to stay in a reviewed
|
||||
posture, require OpenClaw config SecretRefs to use managed providers, require
|
||||
config auth profiles to carry provider/mode metadata, require governed tools to
|
||||
carry risk and sensitivity metadata, then use `doctor --lint` as the shared
|
||||
carry risk and sensitivity metadata, require sensitive logging redaction, deny
|
||||
telemetry content capture, require session retention maintenance, deny session
|
||||
transcript memory indexing, then use `doctor --lint` as the shared
|
||||
conformance gate.
|
||||
|
||||
Use policy when a workspace needs a durable statement such as "these channels
|
||||
@@ -52,7 +54,7 @@ doctor can report the missing artifact.
|
||||
Policy is authored, not generated from the user's current settings. A minimal
|
||||
policy for channels, MCP servers, model providers, network posture, ingress/channel access, Gateway
|
||||
exposure, agent workspace posture, configured sandbox runtime posture, OpenClaw
|
||||
config secret provider/auth profile posture, and tool metadata looks like this:
|
||||
data-handling posture, config secret provider/auth profile posture, and tool metadata looks like this:
|
||||
|
||||
```jsonc
|
||||
{
|
||||
@@ -118,6 +120,20 @@ config secret provider/auth profile posture, and tool metadata looks like this:
|
||||
"denyTools": ["exec", "process", "write", "edit", "apply_patch"],
|
||||
},
|
||||
},
|
||||
"dataHandling": {
|
||||
"sensitiveLogging": {
|
||||
"requireRedaction": true,
|
||||
},
|
||||
"telemetry": {
|
||||
"denyContentCapture": true,
|
||||
},
|
||||
"retention": {
|
||||
"requireSessionMaintenance": true,
|
||||
},
|
||||
"memory": {
|
||||
"denySessionTranscriptIndexing": true,
|
||||
},
|
||||
},
|
||||
"secrets": {
|
||||
"requireManagedProviders": true,
|
||||
"denySources": ["exec"],
|
||||
@@ -155,7 +171,8 @@ when a concrete rule is present. OpenClaw reads current `channels.*` settings
|
||||
`mcp.servers.*`, `models.providers.*`, selected agent model refs, network SSRF
|
||||
settings, direct-message session scope, channel DM policy, channel group policy,
|
||||
channel/group mention gates, Gateway bind/auth/Control UI/Tailscale/remote/HTTP
|
||||
posture, OpenClaw config agent sandbox workspace access and tool deny posture, config secret
|
||||
posture, OpenClaw config agent sandbox workspace access and tool deny posture,
|
||||
data-handling config posture, config secret
|
||||
provider and SecretRef provenance, config auth profile metadata, configured
|
||||
global/per-agent tool posture, and `TOOLS.md` declarations as evidence, then
|
||||
reports observed state that does not conform. If a policy denies non-loopback
|
||||
@@ -176,6 +193,11 @@ runtime. Secret evidence records
|
||||
provider/source posture and SecretRef metadata, never raw secret values. Policy
|
||||
does not read or attest per-agent credential stores such as `auth-profiles.json`;
|
||||
those stores remain owned by the existing auth and credential flows.
|
||||
Data-handling evidence is config-level posture only: it checks configured
|
||||
redaction mode, telemetry content-capture toggles, session maintenance mode, and
|
||||
session-transcript memory indexing settings. It does not inspect raw logs,
|
||||
telemetry exports, transcript contents, memory files, or prove that no personal
|
||||
data or secrets exist.
|
||||
|
||||
### Policy rule reference
|
||||
|
||||
@@ -183,6 +205,8 @@ Each policy field below is optional. A check runs only when the matching rule is
|
||||
present in `policy.jsonc`. The observed state is existing OpenClaw config or
|
||||
workspace metadata; policy reports drift but does not rewrite runtime behavior
|
||||
unless a repair path is explicitly available and enabled.
|
||||
Policy files are strict: unsupported sections or rule keys are reported as
|
||||
`policy/policy-jsonc-invalid` instead of being ignored.
|
||||
|
||||
Policy overlays keep broad top-level rules global, then let named scope blocks
|
||||
add stricter normal policy sections for explicit selectors. A scope name is a
|
||||
@@ -194,7 +218,8 @@ its own finding against the same observed config.
|
||||
|
||||
Use `scopes.<scopeName>` when one set of agents or channels needs stricter
|
||||
policy than the top-level baseline. Agent-scoped sections use `agentIds`, which
|
||||
supports `tools.*`, `agents.workspace.*`, and `sandbox.*`. Channel-scoped
|
||||
supports `tools.*`, `agents.workspace.*`, `sandbox.*`, and
|
||||
`dataHandling.memory.*`. Channel-scoped
|
||||
ingress uses `channelIds`, which supports `ingress.channels.*`. Unsupported
|
||||
sections are rejected instead of being ignored. If an `agentIds` entry is not
|
||||
present in `agents.list[]`, OpenClaw evaluates the scoped rule against inherited
|
||||
@@ -233,6 +258,11 @@ global/default posture for that runtime agent id.
|
||||
"requireMode": ["all"],
|
||||
"allowBackends": ["docker"],
|
||||
},
|
||||
"dataHandling": {
|
||||
"memory": {
|
||||
"denySessionTranscriptIndexing": true,
|
||||
},
|
||||
},
|
||||
},
|
||||
"shell-sandbox": {
|
||||
"agentIds": ["shell-agent"],
|
||||
@@ -274,10 +304,10 @@ groups where those fields cannot be observed.
|
||||
Top-level `ingress.session.requireDmScope` remains global because
|
||||
`session.dmScope` is not channel-attributable evidence.
|
||||
|
||||
| Selector | Supported sections | Use when |
|
||||
| ------------ | ------------------------------------------ | ------------------------------------------------- |
|
||||
| `agentIds` | `tools`, `agents.workspace`, and `sandbox` | One or more runtime agents need stricter rules. |
|
||||
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
|
||||
| Selector | Supported sections | Use when |
|
||||
| ------------ | ----------------------------------------------------------------- | ------------------------------------------------- |
|
||||
| `agentIds` | `tools`, `agents.workspace`, `sandbox`, and `dataHandling.memory` | One or more runtime agents need stricter rules. |
|
||||
| `channelIds` | `ingress.channels` | One or more channels need stricter ingress rules. |
|
||||
|
||||
Every scope present in `policy.jsonc` must be valid and enforceable.
|
||||
|
||||
@@ -354,6 +384,15 @@ Policy treats missing `sandbox.mode` as the implicit default `off`, so
|
||||
`sandbox.requireMode` reports a fresh or unconfigured sandbox as outside an
|
||||
allowlist such as `["all"]`.
|
||||
|
||||
#### Data Handling
|
||||
|
||||
| Policy field | Observed state | Use when |
|
||||
| --------------------------------------------------- | ------------------------------------------------------------------------------------ | ---------------------------------------------------------------------- |
|
||||
| `dataHandling.sensitiveLogging.requireRedaction` | `logging.redactSensitive` | Set to `true` to reject `logging.redactSensitive: "off"`. |
|
||||
| `dataHandling.telemetry.denyContentCapture` | `diagnostics.otel.captureContent` | Set to `true` to reject telemetry content capture. |
|
||||
| `dataHandling.retention.requireSessionMaintenance` | `session.maintenance.mode` | Set to `true` to require effective session maintenance mode `enforce`. |
|
||||
| `dataHandling.memory.denySessionTranscriptIndexing` | `memory.qmd.sessions.enabled` and `agents.*.memorySearch.experimental.sessionMemory` | Set to `true` to reject session transcript indexing into memory. |
|
||||
|
||||
#### Secrets
|
||||
|
||||
| Policy field | Observed state | Use when |
|
||||
@@ -674,63 +713,67 @@ choose a different interval.
|
||||
|
||||
Policy currently verifies:
|
||||
|
||||
| Check id | Finding |
|
||||
| ------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
|
||||
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or contains malformed rule entries. |
|
||||
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
|
||||
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
|
||||
| `policy/policy-conformance-invalid` | A baseline or checked policy file has invalid comparison syntax. |
|
||||
| `policy/policy-conformance-missing` | A checked policy file is missing a rule required by the baseline policy file. |
|
||||
| `policy/policy-conformance-weaker` | A checked policy file has a weaker value than the baseline policy file. |
|
||||
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
|
||||
| `policy/mcp-denied-server` | A configured MCP server is denied by policy. |
|
||||
| `policy/mcp-unapproved-server` | A configured MCP server is outside the allowlist. |
|
||||
| `policy/models-denied-provider` | A configured model provider or model ref uses a denied provider. |
|
||||
| `policy/models-unapproved-provider` | A configured model provider or model ref is outside the allowlist. |
|
||||
| `policy/network-private-access-enabled` | A private-network SSRF escape hatch is enabled when policy denies it. |
|
||||
| `policy/ingress-dm-policy-unapproved` | A channel DM policy is outside the policy allowlist. |
|
||||
| `policy/ingress-dm-scope-unapproved` | `session.dmScope` does not match the policy-required DM isolation scope. |
|
||||
| `policy/ingress-open-groups-denied` | A channel group policy is `open` while policy denies open group ingress. |
|
||||
| `policy/ingress-group-mention-required` | A channel or group entry disables mention gates while policy requires them. |
|
||||
| `policy/gateway-non-loopback-bind` | Gateway bind posture permits non-loopback exposure when policy denies it. |
|
||||
| `policy/gateway-auth-disabled` | Gateway authentication is disabled when policy requires auth. |
|
||||
| `policy/gateway-rate-limit-missing` | Gateway auth rate-limit posture is not explicit when policy requires it. |
|
||||
| `policy/gateway-control-ui-insecure` | Gateway Control UI insecure exposure toggles are enabled. |
|
||||
| `policy/gateway-tailscale-funnel` | Gateway Tailscale Funnel exposure is enabled when policy denies it. |
|
||||
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
|
||||
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
|
||||
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
|
||||
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
|
||||
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
|
||||
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |
|
||||
| `policy/tools-fs-workspace-only-required` | Filesystem tools are not configured with workspace-only path posture. |
|
||||
| `policy/tools-exec-security-unapproved` | Exec security mode is outside the policy allowlist. |
|
||||
| `policy/tools-exec-ask-unapproved` | Exec ask mode is outside the policy allowlist. |
|
||||
| `policy/tools-exec-host-unapproved` | Exec host routing is outside the policy allowlist. |
|
||||
| `policy/tools-elevated-enabled` | Elevated tool mode is enabled when policy denies it. |
|
||||
| `policy/tools-also-allow-missing` | A configured `alsoAllow` list is missing an entry required by policy. |
|
||||
| `policy/tools-also-allow-unexpected` | A configured `alsoAllow` list includes an entry not expected by policy. |
|
||||
| `policy/tools-required-deny-missing` | A global or per-agent tool deny list does not include a required denied tool. |
|
||||
| `policy/sandbox-mode-unapproved` | Sandbox mode is outside the policy allowlist. |
|
||||
| `policy/sandbox-backend-unapproved` | Sandbox backend is outside the policy allowlist. |
|
||||
| `policy/sandbox-container-posture-unobservable` | A container posture rule is enabled for a backend that cannot observe it. |
|
||||
| `policy/sandbox-container-host-network-denied` | A container-backed sandbox or browser uses host network mode. |
|
||||
| `policy/sandbox-container-namespace-join-denied` | A container-backed sandbox or browser joins another container namespace. |
|
||||
| `policy/sandbox-container-mount-mode-required` | A container-backed sandbox or browser mount is not read-only. |
|
||||
| `policy/sandbox-container-runtime-socket-mount` | A container-backed sandbox or browser mount exposes the container runtime socket. |
|
||||
| `policy/sandbox-container-unconfined-profile` | Container sandbox profile is unconfined when policy denies it. |
|
||||
| `policy/sandbox-browser-cdp-source-range-missing` | Sandbox browser CDP source range is missing when policy requires one. |
|
||||
| `policy/secrets-unmanaged-provider` | A config SecretRef references a provider not declared under `secrets.providers`. |
|
||||
| `policy/secrets-denied-provider-source` | A config secret provider or SecretRef uses a source denied by policy. |
|
||||
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
|
||||
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
|
||||
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
|
||||
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
|
||||
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
|
||||
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
|
||||
| `policy/tools-missing-owner` | A governed tool declaration is missing owner metadata. |
|
||||
| `policy/tools-unknown-sensitivity-token` | A governed tool declaration uses an unknown sensitivity value. |
|
||||
| Check id | Finding |
|
||||
| -------------------------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| `policy/policy-jsonc-missing` | Policy is enabled but `policy.jsonc` is missing. |
|
||||
| `policy/policy-jsonc-invalid` | Policy cannot be parsed or contains malformed rule entries. |
|
||||
| `policy/policy-hash-mismatch` | Policy does not match configured `expectedHash`. |
|
||||
| `policy/attestation-hash-mismatch` | Current policy evidence no longer matches the accepted attestation. |
|
||||
| `policy/policy-conformance-invalid` | A baseline or checked policy file has invalid comparison syntax. |
|
||||
| `policy/policy-conformance-missing` | A checked policy file is missing a rule required by the baseline policy file. |
|
||||
| `policy/policy-conformance-weaker` | A checked policy file has a weaker value than the baseline policy file. |
|
||||
| `policy/channels-denied-provider` | An enabled channel matches a channel deny rule. |
|
||||
| `policy/mcp-denied-server` | A configured MCP server is denied by policy. |
|
||||
| `policy/mcp-unapproved-server` | A configured MCP server is outside the allowlist. |
|
||||
| `policy/models-denied-provider` | A configured model provider or model ref uses a denied provider. |
|
||||
| `policy/models-unapproved-provider` | A configured model provider or model ref is outside the allowlist. |
|
||||
| `policy/network-private-access-enabled` | A private-network SSRF escape hatch is enabled when policy denies it. |
|
||||
| `policy/ingress-dm-policy-unapproved` | A channel DM policy is outside the policy allowlist. |
|
||||
| `policy/ingress-dm-scope-unapproved` | `session.dmScope` does not match the policy-required DM isolation scope. |
|
||||
| `policy/ingress-open-groups-denied` | A channel group policy is `open` while policy denies open group ingress. |
|
||||
| `policy/ingress-group-mention-required` | A channel or group entry disables mention gates while policy requires them. |
|
||||
| `policy/gateway-non-loopback-bind` | Gateway bind posture permits non-loopback exposure when policy denies it. |
|
||||
| `policy/gateway-auth-disabled` | Gateway authentication is disabled when policy requires auth. |
|
||||
| `policy/gateway-rate-limit-missing` | Gateway auth rate-limit posture is not explicit when policy requires it. |
|
||||
| `policy/gateway-control-ui-insecure` | Gateway Control UI insecure exposure toggles are enabled. |
|
||||
| `policy/gateway-tailscale-funnel` | Gateway Tailscale Funnel exposure is enabled when policy denies it. |
|
||||
| `policy/gateway-remote-enabled` | Gateway remote mode is active when policy denies it. |
|
||||
| `policy/gateway-http-endpoint-enabled` | A Gateway HTTP API endpoint is enabled while denied by policy. |
|
||||
| `policy/gateway-http-url-fetch-unrestricted` | Gateway HTTP URL-fetch input lacks a required URL allowlist. |
|
||||
| `policy/agents-workspace-access-denied` | Agent sandbox mode or workspace access is outside the policy allowlist. |
|
||||
| `policy/agents-tool-not-denied` | An agent or default config does not deny a tool required by policy. |
|
||||
| `policy/tools-profile-unapproved` | A configured global or per-agent tool profile is outside the allowlist. |
|
||||
| `policy/tools-fs-workspace-only-required` | Filesystem tools are not configured with workspace-only path posture. |
|
||||
| `policy/tools-exec-security-unapproved` | Exec security mode is outside the policy allowlist. |
|
||||
| `policy/tools-exec-ask-unapproved` | Exec ask mode is outside the policy allowlist. |
|
||||
| `policy/tools-exec-host-unapproved` | Exec host routing is outside the policy allowlist. |
|
||||
| `policy/tools-elevated-enabled` | Elevated tool mode is enabled when policy denies it. |
|
||||
| `policy/tools-also-allow-missing` | A configured `alsoAllow` list is missing an entry required by policy. |
|
||||
| `policy/tools-also-allow-unexpected` | A configured `alsoAllow` list includes an entry not expected by policy. |
|
||||
| `policy/tools-required-deny-missing` | A global or per-agent tool deny list does not include a required denied tool. |
|
||||
| `policy/sandbox-mode-unapproved` | Sandbox mode is outside the policy allowlist. |
|
||||
| `policy/sandbox-backend-unapproved` | Sandbox backend is outside the policy allowlist. |
|
||||
| `policy/sandbox-container-posture-unobservable` | A container posture rule is enabled for a backend that cannot observe it. |
|
||||
| `policy/sandbox-container-host-network-denied` | A container-backed sandbox or browser uses host network mode. |
|
||||
| `policy/sandbox-container-namespace-join-denied` | A container-backed sandbox or browser joins another container namespace. |
|
||||
| `policy/sandbox-container-mount-mode-required` | A container-backed sandbox or browser mount is not read-only. |
|
||||
| `policy/sandbox-container-runtime-socket-mount` | A container-backed sandbox or browser mount exposes the container runtime socket. |
|
||||
| `policy/sandbox-container-unconfined-profile` | Container sandbox profile is unconfined when policy denies it. |
|
||||
| `policy/sandbox-browser-cdp-source-range-missing` | Sandbox browser CDP source range is missing when policy requires one. |
|
||||
| `policy/data-handling-redaction-disabled` | Sensitive logging redaction is disabled when policy requires it. |
|
||||
| `policy/data-handling-telemetry-content-capture` | Telemetry content capture is enabled when policy denies it. |
|
||||
| `policy/data-handling-session-retention-not-enforced` | Session retention maintenance is not enforced when policy requires it. |
|
||||
| `policy/data-handling-session-transcript-memory-enabled` | Session transcript memory indexing is enabled when policy denies it. |
|
||||
| `policy/secrets-unmanaged-provider` | A config SecretRef references a provider not declared under `secrets.providers`. |
|
||||
| `policy/secrets-denied-provider-source` | A config secret provider or SecretRef uses a source denied by policy. |
|
||||
| `policy/secrets-insecure-provider` | A secret provider opts into insecure posture when policy denies it. |
|
||||
| `policy/auth-profile-invalid-metadata` | A config auth profile is missing valid provider or mode metadata. |
|
||||
| `policy/auth-profile-unapproved-mode` | A config auth profile mode is outside the policy allowlist. |
|
||||
| `policy/tools-missing-risk-level` | A governed tool declaration is missing risk metadata. |
|
||||
| `policy/tools-unknown-risk-level` | A governed tool declaration uses an unknown risk value. |
|
||||
| `policy/tools-missing-sensitivity-token` | A governed tool declaration is missing sensitivity metadata. |
|
||||
| `policy/tools-missing-owner` | A governed tool declaration is missing owner metadata. |
|
||||
| `policy/tools-unknown-sensitivity-token` | A governed tool declaration uses an unknown sensitivity value. |
|
||||
|
||||
Policy findings can include both `target` and `requirement`. `target` is the
|
||||
observed workspace thing that does not conform. `requirement` is the authored
|
||||
|
||||
@@ -194,10 +194,12 @@ OpenClaw resolves that behavior by conversation type:
|
||||
`message(action=send)`.
|
||||
- Internal orchestration allows silence by default.
|
||||
|
||||
OpenClaw also uses silent replies for internal runner failures that happen
|
||||
before any assistant reply in non-direct chats, so groups/channels do not see
|
||||
gateway error boilerplate. Direct chats show compact failure copy by default;
|
||||
raw runner details are shown only when `/verbose full` is enabled.
|
||||
OpenClaw also uses silent replies for generic internal runner failures in
|
||||
non-direct chats, so groups/channels do not see gateway error boilerplate.
|
||||
Classified failures with user-facing recovery copy, such as missing auth,
|
||||
rate-limit, or overload notices, can still be delivered. Direct chats show
|
||||
compact failure copy by default; raw runner details are shown only when
|
||||
`/verbose full` is enabled.
|
||||
|
||||
Defaults live under `agents.defaults.silentReply`; `surfaces.<id>.silentReply`
|
||||
can override group/internal policy per surface.
|
||||
|
||||
@@ -110,8 +110,8 @@ writes.
|
||||
## Session maintenance
|
||||
|
||||
OpenClaw automatically bounds session storage over time. By default, it runs
|
||||
in `warn` mode (reports what would be cleaned). Set `session.maintenance.mode`
|
||||
to `"enforce"` for automatic cleanup:
|
||||
in `enforce` mode and applies cleanup during maintenance. Set
|
||||
`session.maintenance.mode` to `"warn"` to report what would be cleaned without mutating the store/files:
|
||||
|
||||
```json5
|
||||
{
|
||||
|
||||
@@ -1272,7 +1272,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
resetTriggers: ["/new", "/reset"],
|
||||
store: "~/.openclaw/agents/{agentId}/sessions/sessions.json",
|
||||
maintenance: {
|
||||
mode: "warn", // warn | enforce
|
||||
mode: "enforce", // enforce (default) | warn
|
||||
pruneAfter: "30d",
|
||||
maxEntries: 500,
|
||||
resetArchiveRetention: "30d", // duration or false
|
||||
@@ -1311,7 +1311,7 @@ See [Multi-Agent Sandbox & Tools](/tools/multi-agent-sandbox-tools) for preceden
|
||||
- **`agentToAgent.maxPingPongTurns`**: maximum reply-back turns between agents during agent-to-agent exchanges (integer, range: `0`-`20`, default: `5`). `0` disables ping-pong chaining.
|
||||
- **`sendPolicy`**: match by `channel`, `chatType` (`direct|group|channel`, with legacy `dm` alias), `keyPrefix`, or `rawKeyPrefix`. First deny wins.
|
||||
- **`maintenance`**: session-store cleanup + retention controls.
|
||||
- `mode`: `warn` emits warnings only; `enforce` applies cleanup.
|
||||
- `mode`: `enforce` applies cleanup and is the default; `warn` emits warnings only.
|
||||
- `pruneAfter`: age cutoff for stale entries (default `30d`).
|
||||
- `maxEntries`: maximum number of entries in `sessions.json` (default `500`). Runtime writes batch cleanup with a small high-water buffer for production-sized caps; `openclaw sessions cleanup --enforce` applies the cap immediately.
|
||||
- `rotateBytes`: deprecated and ignored; `openclaw doctor --fix` removes it from older configs.
|
||||
|
||||
@@ -27,7 +27,7 @@ settings and governed workspace declarations. Policy currently covers channel
|
||||
conformance, governed tool metadata, MCP server posture, model-provider posture,
|
||||
private-network access posture, Gateway exposure posture, agent workspace/tool
|
||||
posture, configured global/per-agent tool posture, configured sandbox runtime
|
||||
posture, ingress/channel access posture, and OpenClaw config secret
|
||||
posture, ingress/channel access posture, data-handling posture, and OpenClaw config secret
|
||||
provider/auth profile posture.
|
||||
|
||||
Policy stores authored requirements in `policy.jsonc`, observes existing
|
||||
@@ -55,9 +55,16 @@ and require sandbox browser CDP source ranges.
|
||||
These checks observe config conformance only; they do not read runtime approval
|
||||
state, inspect live containers, or add runtime enforcement.
|
||||
|
||||
Data-handling rules can require sensitive logging redaction, deny telemetry
|
||||
content capture, require session retention maintenance, and deny session
|
||||
transcript memory indexing. These checks observe config conformance only; they
|
||||
do not inspect raw logs, telemetry exports, transcripts, memory files, secrets,
|
||||
or personal data.
|
||||
|
||||
Named policy scopes under `scopes.<scopeName>` can add stricter normal policy
|
||||
sections for the selector they list. `agentIds` supports `tools`,
|
||||
`agents.workspace`, and `sandbox`; `channelIds` supports `ingress.channels`.
|
||||
`agents.workspace`, `sandbox`, and `dataHandling.memory`; `channelIds` supports
|
||||
`ingress.channels`.
|
||||
Runtime agent ids that are not explicitly listed in `agents.list[]` are checked
|
||||
against inherited global/default posture rather than silently passing with no
|
||||
evidence. Every scope present in `policy.jsonc` must be valid and enforceable
|
||||
|
||||
@@ -292,7 +292,8 @@ Workboard stops auto-moving that card until you move it back to `todo` or
|
||||
2. Create a card with a title, notes, priority, labels, optional agent, and
|
||||
optional linked session.
|
||||
3. Or open Sessions and choose Add to Workboard for an existing session.
|
||||
4. Drag the card between columns or use the column controls.
|
||||
4. Drag the card between columns or focus the compact status control on the card
|
||||
and use its menu or ArrowLeft/ArrowRight.
|
||||
5. Start work from the card to create or reuse a dashboard session.
|
||||
6. Open the linked session from the card while the agent works.
|
||||
7. Let lifecycle sync move running work into review or blocked, then manually
|
||||
|
||||
@@ -78,7 +78,7 @@ OpenClaw resolves these via `src/config/sessions.ts`.
|
||||
|
||||
Session persistence has automatic maintenance controls (`session.maintenance`) for `sessions.json`, transcript artifacts, and trajectory sidecars:
|
||||
|
||||
- `mode`: `warn` (default) or `enforce`
|
||||
- `mode`: `enforce` (default) or `warn`
|
||||
- `pruneAfter`: stale-entry age cutoff (default `30d`)
|
||||
- `maxEntries`: cap entries in `sessions.json` (default `500`)
|
||||
- `resetArchiveRetention`: retention for `*.reset.<timestamp>` transcript archives (default: same as `pruneAfter`; `false` disables cleanup)
|
||||
|
||||
@@ -144,9 +144,15 @@ when set at the narrower session or agent scope.
|
||||
### `exec.ask`
|
||||
|
||||
<ParamField path="ask" type='"off" | "on-miss" | "always"'>
|
||||
- `off` - never prompt.
|
||||
- `on-miss` - prompt only when the allowlist does not match.
|
||||
- `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
|
||||
Configured ask policy for host exec. Controls the baseline approval
|
||||
prompt behavior from `tools.exec.ask` and host approvals defaults. The
|
||||
per-call `ask` tool parameter (see [Exec tool](/tools/exec#parameters))
|
||||
can only harden that baseline, and channel-origin model calls ignore it
|
||||
when the effective host ask is `off`.
|
||||
|
||||
- `off` - never prompt.
|
||||
- `on-miss` - prompt only when the allowlist does not match.
|
||||
- `always` - prompt on every command. `allow-always` durable trust does **not** suppress prompts when effective ask mode is `always`.
|
||||
|
||||
</ParamField>
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ force `security=full` only when the operator explicitly grants elevated access.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="ask" type="'off' | 'on-miss' | 'always'">
|
||||
Approval prompt behavior for `gateway` / `node` execution.
|
||||
The baseline ask mode comes from `tools.exec.ask` and host approvals.
|
||||
For channel-origin model calls, per-call `ask` is ignored when the
|
||||
effective host ask is `off`; otherwise it can only harden to a stricter
|
||||
mode. Trusted internal/API callers that construct exec tools with an
|
||||
explicit `ask` value are unchanged.
|
||||
</ParamField>
|
||||
|
||||
<ParamField path="node" type="string">
|
||||
|
||||
@@ -180,7 +180,7 @@ Activity entries keep only sanitized summaries and redacted, truncated output pr
|
||||
|
||||
<AccordionGroup>
|
||||
<Accordion title="Send and history semantics">
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events.
|
||||
- `chat.send` is **non-blocking**: it acks immediately with `{ runId, status: "started" }` and the response streams via `chat` events. Trusted Control UI clients may also receive optional ACK timing metadata for local diagnostics.
|
||||
- Chat uploads accept images plus non-video files. Images keep the native image path; other files are stored as managed media and shown in history as attachment links.
|
||||
- Re-sending with the same `idempotencyKey` returns `{ status: "in_flight" }` while running, and `{ status: "ok" }` after completion.
|
||||
- `chat.history` responses are size-bounded for UI safety. When transcript entries are too large, Gateway may truncate long text fields, omit heavy metadata blocks, and replace oversized messages with a placeholder (`[chat.history omitted: message too large]`).
|
||||
|
||||
@@ -34,6 +34,23 @@ describe("canvas a2ui copy", () => {
|
||||
);
|
||||
}
|
||||
|
||||
it("ships provider assets and the legacy granola compatibility image", async () => {
|
||||
const srcDir = path.join(process.cwd(), "extensions", "canvas", "src", "host", "a2ui");
|
||||
const requiredAssets = [
|
||||
path.join("assets", "providers", "google.png"),
|
||||
path.join("assets", "providers", "x.png"),
|
||||
"granola.png",
|
||||
];
|
||||
const pngSignature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
|
||||
|
||||
for (const asset of requiredAssets) {
|
||||
const bytes = await fs.readFile(path.join(srcDir, asset));
|
||||
|
||||
expect([...bytes.subarray(0, pngSignature.length)]).toEqual(pngSignature);
|
||||
expect(bytes.length).toBeGreaterThan(64);
|
||||
}
|
||||
});
|
||||
|
||||
it("throws a helpful error when assets are missing", async () => {
|
||||
await withA2uiFixture(async (dir) => {
|
||||
await expect(copyA2uiAssets({ srcDir: dir, outDir: path.join(dir, "out") })).rejects.toThrow(
|
||||
@@ -78,4 +95,30 @@ describe("canvas a2ui copy", () => {
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves provider assets and the legacy granola compatibility image", async () => {
|
||||
await withA2uiFixture(async (dir) => {
|
||||
const srcDir = path.join(dir, "src");
|
||||
const outDir = path.join(dir, "dist");
|
||||
const providerAssetDir = path.join(srcDir, "assets", "providers");
|
||||
await fs.mkdir(providerAssetDir, { recursive: true });
|
||||
await fs.writeFile(path.join(srcDir, "index.html"), "<html></html>", "utf8");
|
||||
await fs.writeFile(path.join(srcDir, "a2ui.bundle.js"), "console.log(1);", "utf8");
|
||||
await fs.writeFile(path.join(providerAssetDir, "google.png"), "google-asset", "utf8");
|
||||
await fs.writeFile(path.join(providerAssetDir, "x.png"), "x-asset", "utf8");
|
||||
await fs.writeFile(path.join(srcDir, "granola.png"), "legacy-granola-asset", "utf8");
|
||||
|
||||
await copyA2uiAssets({ srcDir, outDir });
|
||||
|
||||
await expect(
|
||||
fs.readFile(path.join(outDir, "assets", "providers", "google.png"), "utf8"),
|
||||
).resolves.toBe("google-asset");
|
||||
await expect(
|
||||
fs.readFile(path.join(outDir, "assets", "providers", "x.png"), "utf8"),
|
||||
).resolves.toBe("x-asset");
|
||||
await expect(fs.readFile(path.join(outDir, "granola.png"), "utf8")).resolves.toBe(
|
||||
"legacy-granola-asset",
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
BIN
extensions/canvas/src/host/a2ui/assets/providers/google.png
Normal file
BIN
extensions/canvas/src/host/a2ui/assets/providers/google.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 86 KiB |
BIN
extensions/canvas/src/host/a2ui/assets/providers/x.png
Normal file
BIN
extensions/canvas/src/host/a2ui/assets/providers/x.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
extensions/canvas/src/host/a2ui/granola.png
Normal file
BIN
extensions/canvas/src/host/a2ui/granola.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 258 KiB |
@@ -1,5 +1,5 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildCodexMediaUnderstandingProvider } from "./media-understanding-provider.js";
|
||||
import type { CodexAppServerClient } from "./src/app-server/client.js";
|
||||
import type { CodexServerNotification, JsonValue } from "./src/app-server/protocol.js";
|
||||
@@ -174,6 +174,11 @@ function createFakeClient(options?: {
|
||||
}
|
||||
|
||||
describe("codex media understanding provider", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("runs image understanding through a bounded Codex app-server turn", async () => {
|
||||
const { client, requests } = createFakeClient();
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
@@ -231,9 +236,8 @@ describe("codex media understanding provider", () => {
|
||||
});
|
||||
|
||||
it("clamps oversized image understanding turn timeouts", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const { client } = createFakeClient();
|
||||
const provider = buildCodexMediaUnderstandingProvider({
|
||||
clientFactory: async () => client,
|
||||
|
||||
@@ -7,7 +7,11 @@ import { startCodexAttemptThread } from "./attempt-startup.js";
|
||||
import { defaultLeasedCodexAppServerClientFactory } from "./client-factory.js";
|
||||
import { CodexAppServerClient } from "./client.js";
|
||||
import { type CodexPluginConfig, resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import { clearSharedCodexAppServerClient } from "./shared-client.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClient,
|
||||
getLeasedSharedCodexAppServerClient,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
} from "./shared-client.js";
|
||||
import { createClientHarness, createCodexTestModel } from "./test-support.js";
|
||||
|
||||
type ClientHarness = ReturnType<typeof createClientHarness>;
|
||||
@@ -44,6 +48,8 @@ const bundleMcpThreadConfig = {
|
||||
fingerprint: undefined,
|
||||
} satisfies CodexBundleMcpThreadConfig;
|
||||
|
||||
const HARNESS_REQUEST_TIMEOUT_MS = 15_000;
|
||||
|
||||
function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: string }> {
|
||||
return writes.map((write) => JSON.parse(write) as { id?: number; method?: string });
|
||||
}
|
||||
@@ -51,14 +57,24 @@ function readHarnessMessages(writes: string[]): Array<{ id?: number; method?: st
|
||||
function startThreadWithHarness(
|
||||
startupTimeoutMs: number,
|
||||
signal = new AbortController().signal,
|
||||
overrides?: { pluginConfig?: CodexPluginConfig },
|
||||
overrides?: {
|
||||
pluginConfig?: CodexPluginConfig;
|
||||
attemptClientFactory?: (
|
||||
harness: ClientHarness,
|
||||
) => Parameters<typeof startCodexAttemptThread>[0]["attemptClientFactory"];
|
||||
harness?: ClientHarness;
|
||||
skipStartSpy?: boolean;
|
||||
},
|
||||
) {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
const harness = overrides?.harness ?? createClientHarness();
|
||||
if (!overrides?.skipStartSpy) {
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
}
|
||||
const effectivePluginConfig = overrides?.pluginConfig ?? pluginConfig;
|
||||
|
||||
const run = startCodexAttemptThread({
|
||||
attemptClientFactory: defaultLeasedCodexAppServerClientFactory,
|
||||
attemptClientFactory:
|
||||
overrides?.attemptClientFactory?.(harness) ?? defaultLeasedCodexAppServerClientFactory,
|
||||
appServer: resolveCodexAppServerRuntimeOptions({ pluginConfig: effectivePluginConfig }),
|
||||
pluginConfig: effectivePluginConfig,
|
||||
computerUseConfig: effectivePluginConfig.computerUse ?? { enabled: false },
|
||||
@@ -91,7 +107,7 @@ function startThreadWithHarness(
|
||||
async function answerInitialize(harness: ClientHarness): Promise<void> {
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1), {
|
||||
interval: 1,
|
||||
timeout: 5_000,
|
||||
timeout: HARNESS_REQUEST_TIMEOUT_MS,
|
||||
});
|
||||
const initialize = JSON.parse(harness.writes[0] ?? "{}") as { id?: number };
|
||||
harness.send({ id: initialize.id, result: { userAgent: "openclaw/0.125.0 (macOS; test)" } });
|
||||
@@ -106,7 +122,7 @@ async function waitForRequest(
|
||||
expect(readHarnessMessages(harness.writes).some((write) => write.method === method)).toBe(
|
||||
true,
|
||||
),
|
||||
{ interval: 1, timeout: 5_000 },
|
||||
{ interval: 1, timeout: HARNESS_REQUEST_TIMEOUT_MS },
|
||||
);
|
||||
const request = readHarnessMessages(harness.writes).find((write) => write.method === method);
|
||||
if (!request) {
|
||||
@@ -147,8 +163,50 @@ describe("startCodexAttemptThread", () => {
|
||||
expect(harness.process.stdin.destroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("retires a failed startup client after another active lease releases", async () => {
|
||||
const retained = createClientHarness();
|
||||
const replacement = createClientHarness();
|
||||
const startSpy = vi
|
||||
.spyOn(CodexAppServerClient, "start")
|
||||
.mockReturnValueOnce(retained.client)
|
||||
.mockReturnValueOnce(replacement.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(5_000, new AbortController().signal, {
|
||||
harness: retained,
|
||||
skipStartSpy: true,
|
||||
});
|
||||
const threadStart = await waitForThreadStart(retained);
|
||||
retained.send({
|
||||
id: threadStart.id,
|
||||
error: { code: -32000, message: "401 authentication_error: Invalid bearer token" },
|
||||
});
|
||||
|
||||
await expect(run).rejects.toThrow("Invalid bearer token");
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
|
||||
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
|
||||
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
|
||||
|
||||
const replacementLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
await answerInitialize(replacement);
|
||||
await expect(replacementLease).resolves.toBe(replacement.client);
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
expect(releaseLeasedSharedCodexAppServerClient(replacement.client)).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the shared app-server when startup abandons an in-flight thread request", async () => {
|
||||
const { harness, run } = startThreadWithHarness(200);
|
||||
const { harness, run } = startThreadWithHarness(2_000);
|
||||
const runError = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
@@ -166,9 +224,99 @@ describe("startCodexAttemptThread", () => {
|
||||
expect(harness.stdinDestroyed).toBe(true);
|
||||
});
|
||||
|
||||
it("aborts abandoned thread startup when another lease keeps the shared app-server alive", async () => {
|
||||
const retained = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(retained.client);
|
||||
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig });
|
||||
|
||||
const retainedLease = getLeasedSharedCodexAppServerClient({
|
||||
startOptions: appServer.start,
|
||||
agentDir: "/tmp/agent",
|
||||
});
|
||||
await answerInitialize(retained);
|
||||
await expect(retainedLease).resolves.toBe(retained.client);
|
||||
|
||||
const { run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
harness: retained,
|
||||
skipStartSpy: true,
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
const threadStart = await waitForThreadStart(retained);
|
||||
|
||||
await rejected;
|
||||
expect(retained.process.stdin.destroyed).toBe(false);
|
||||
|
||||
retained.send({ id: threadStart.id, result: { threadId: "late-thread" } });
|
||||
expect(releaseLeasedSharedCodexAppServerClient(retained.client)).toBe(true);
|
||||
await vi.waitFor(() => expect(retained.process.stdin.destroyed).toBe(true));
|
||||
});
|
||||
|
||||
it("closes the shared app-server when startup times out during initialize", async () => {
|
||||
const { harness, run } = startThreadWithHarness(2_000);
|
||||
const runError = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
);
|
||||
|
||||
const initialize = await waitForRequest(harness, "initialize");
|
||||
expect(initialize.id).toBeDefined();
|
||||
|
||||
const error = await runError;
|
||||
expect(error).toBeInstanceOf(Error);
|
||||
expect((error as Error).message).toBe("codex app-server startup timed out");
|
||||
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
|
||||
interval: 1,
|
||||
timeout: 2_000,
|
||||
});
|
||||
expect(
|
||||
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("closes a startup client that arrives after startup timeout", async () => {
|
||||
let observedFactoryOptions:
|
||||
| {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
}
|
||||
| undefined;
|
||||
let resolveFactoryDone: () => void = () => undefined;
|
||||
const factoryDone = new Promise<void>((resolve) => {
|
||||
resolveFactoryDone = resolve;
|
||||
});
|
||||
const { harness, run } = startThreadWithHarness(100, new AbortController().signal, {
|
||||
attemptClientFactory:
|
||||
(factoryHarness) => async (_startOptions, _authProfileId, _agentDir, _config, options) => {
|
||||
try {
|
||||
observedFactoryOptions = options;
|
||||
await new Promise<void>((resolve) => {
|
||||
setTimeout(resolve, 250);
|
||||
});
|
||||
options?.onStartedClient?.(factoryHarness.client);
|
||||
return factoryHarness.client;
|
||||
} finally {
|
||||
resolveFactoryDone();
|
||||
}
|
||||
},
|
||||
});
|
||||
const rejected = expect(run).rejects.toThrow("codex app-server startup timed out");
|
||||
|
||||
await rejected;
|
||||
await factoryDone;
|
||||
await vi.waitFor(() => expect(harness.stdinDestroyed).toBe(true), {
|
||||
interval: 1,
|
||||
timeout: 2_000,
|
||||
});
|
||||
expect(
|
||||
readHarnessMessages(harness.writes).some((write) => write.method === "thread/start"),
|
||||
).toBe(false);
|
||||
expect(observedFactoryOptions?.onStartedClient).toBeTypeOf("function");
|
||||
expect(observedFactoryOptions?.abandonSignal?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("clears the shared app-server when cancellation abandons an in-flight thread request", async () => {
|
||||
const abortController = new AbortController();
|
||||
const { harness, run } = startThreadWithHarness(5_000, abortController.signal);
|
||||
const { harness, run } = startThreadWithHarness(30_000, abortController.signal);
|
||||
const runError = run.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
|
||||
@@ -44,8 +44,10 @@ import {
|
||||
type CodexSandboxExecEnvironment,
|
||||
} from "./sandbox-exec-server.js";
|
||||
import {
|
||||
clearSharedCodexAppServerClientIfCurrentAndUnclaimed,
|
||||
clearSharedCodexAppServerClientIfCurrent,
|
||||
releaseLeasedSharedCodexAppServerClient,
|
||||
retireSharedCodexAppServerClientIfCurrent,
|
||||
} from "./shared-client.js";
|
||||
import {
|
||||
startOrResumeThread,
|
||||
@@ -102,13 +104,23 @@ export async function startCodexAttemptThread(params: {
|
||||
let releaseSharedClientLease: (() => void) | undefined;
|
||||
let startupClientForAbandonedRequestCleanup: CodexAppServerClient | undefined;
|
||||
let releaseStartupResourcesOnTimeout: (() => Promise<void>) | undefined;
|
||||
let startupAbandoned = false;
|
||||
const startupAbandonController = new AbortController();
|
||||
const abandonStartupAcquire = () => startupAbandonController.abort();
|
||||
params.signal.addEventListener("abort", abandonStartupAcquire, { once: true });
|
||||
try {
|
||||
const startupResult = await withCodexStartupTimeout({
|
||||
timeoutMs: params.startupTimeoutMs,
|
||||
signal: params.signal,
|
||||
onTimeout: async () => {
|
||||
startupAbandoned = true;
|
||||
startupAbandonController.abort();
|
||||
await params.onStartupTimeout();
|
||||
await releaseStartupResourcesOnTimeout?.();
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
},
|
||||
operation: async () => {
|
||||
const threadConfig = mergeCodexThreadConfigs(
|
||||
@@ -172,25 +184,48 @@ export async function startCodexAttemptThread(params: {
|
||||
let attemptedClient: CodexAppServerClient | undefined;
|
||||
const startupAttempt = async () => {
|
||||
let startupClientLease: (() => void) | undefined;
|
||||
let startupClient: CodexAppServerClient | undefined;
|
||||
let startupAttemptError: unknown;
|
||||
let startupAttemptSucceeded = false;
|
||||
try {
|
||||
const startupClient = await params.attemptClientFactory(
|
||||
startupClient = await params.attemptClientFactory(
|
||||
params.appServer.start,
|
||||
params.startupAuthProfileId,
|
||||
params.agentDir,
|
||||
params.config,
|
||||
{
|
||||
onStartedClient: (client) => {
|
||||
startupClientForAbandonedRequestCleanup = client;
|
||||
if (startupAbandoned || startupAbandonController.signal.aborted) {
|
||||
void closeAbandonedStartupClient(client);
|
||||
}
|
||||
},
|
||||
abandonSignal: startupAbandonController.signal,
|
||||
},
|
||||
);
|
||||
const activeStartupClient = startupClient;
|
||||
let startupClientLeaseReleased = false;
|
||||
startupClientLease = () => {
|
||||
releaseLeasedSharedCodexAppServerClient(startupClient);
|
||||
if (startupClientLeaseReleased) {
|
||||
return;
|
||||
}
|
||||
startupClientLeaseReleased = true;
|
||||
releaseLeasedSharedCodexAppServerClient(activeStartupClient);
|
||||
};
|
||||
releaseSharedClientLease = startupClientLease;
|
||||
attemptedClient = startupClient;
|
||||
startupClientForAbandonedRequestCleanup = startupClient;
|
||||
attemptedClient = activeStartupClient;
|
||||
startupClientForAbandonedRequestCleanup = activeStartupClient;
|
||||
if (startupAbandoned) {
|
||||
throw new Error("codex app-server startup timed out");
|
||||
}
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
await ensureCodexComputerUse({
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
pluginConfig: params.pluginConfig,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal: params.signal,
|
||||
signal: startupAbandonController.signal,
|
||||
});
|
||||
let startupSandboxEnvironment: CodexSandboxExecEnvironment | undefined;
|
||||
let startupSandboxEnvironmentAcquired = false;
|
||||
@@ -208,15 +243,15 @@ export async function startCodexAttemptThread(params: {
|
||||
sandboxExecServerEnabled: params.sandboxExecServerEnabled,
|
||||
})
|
||||
? await ensureCodexSandboxExecServerEnvironment({
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
sandbox: params.sandbox ?? null,
|
||||
appServerStartOptions: params.appServer.start,
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal: params.signal,
|
||||
signal: startupAbandonController.signal,
|
||||
})
|
||||
: undefined;
|
||||
startupSandboxEnvironmentAcquired = Boolean(startupSandboxEnvironment);
|
||||
if (params.signal.aborted) {
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
@@ -246,9 +281,9 @@ export async function startCodexAttemptThread(params: {
|
||||
const startupSandboxPolicy = startupSandboxEnvironment
|
||||
? resolveCodexExternalSandboxPolicyForOpenClawSandbox(params.sandbox)
|
||||
: undefined;
|
||||
const buildThreadLifecycleParams = () =>
|
||||
const buildThreadLifecycleParams = (signal: AbortSignal) =>
|
||||
({
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
params: params.buildAttemptParams(),
|
||||
agentId: params.sessionAgentId,
|
||||
cwd: startupExecutionCwd,
|
||||
@@ -266,7 +301,7 @@ export async function startCodexAttemptThread(params: {
|
||||
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
contextEngineProjection: params.contextEngineProjection,
|
||||
signal: params.signal,
|
||||
signal,
|
||||
pluginThreadConfig: pluginThreadConfigRequired
|
||||
? {
|
||||
enabled: true,
|
||||
@@ -276,9 +311,9 @@ export async function startCodexAttemptThread(params: {
|
||||
buildCodexPluginThreadConfig({
|
||||
pluginConfig: pluginThreadConfigPluginConfig,
|
||||
request: (method, requestParams) =>
|
||||
startupClient.request(method, requestParams, {
|
||||
activeStartupClient.request(method, requestParams, {
|
||||
timeoutMs: params.appServer.requestTimeoutMs,
|
||||
signal: params.signal,
|
||||
signal,
|
||||
}),
|
||||
appCache: defaultCodexAppInventoryCache,
|
||||
appCacheKey: pluginAppCacheKey,
|
||||
@@ -287,22 +322,24 @@ export async function startCodexAttemptThread(params: {
|
||||
: undefined,
|
||||
}) satisfies Parameters<typeof startOrResumeThread>[0];
|
||||
try {
|
||||
const startupThread = await startOrResumeThread(buildThreadLifecycleParams());
|
||||
if (params.signal.aborted) {
|
||||
const startupThread = await startOrResumeThread(
|
||||
buildThreadLifecycleParams(startupAbandonController.signal),
|
||||
);
|
||||
if (startupAbandonController.signal.aborted) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
throw new Error("codex app-server startup aborted");
|
||||
}
|
||||
startupSandboxEnvironmentAcquired = false;
|
||||
startupAttemptSucceeded = true;
|
||||
return {
|
||||
client: startupClient,
|
||||
client: activeStartupClient,
|
||||
thread: startupThread,
|
||||
sandboxEnvironment: startupSandboxEnvironment,
|
||||
environmentSelection: startupEnvironmentSelection,
|
||||
executionCwd: startupExecutionCwd,
|
||||
sandboxPolicy: startupSandboxPolicy,
|
||||
restartContextEngineCodexThread: () =>
|
||||
startOrResumeThread(buildThreadLifecycleParams()),
|
||||
startOrResumeThread(buildThreadLifecycleParams(params.signal)),
|
||||
};
|
||||
} catch (error) {
|
||||
await releaseStartupSandboxEnvironment();
|
||||
@@ -312,12 +349,32 @@ export async function startCodexAttemptThread(params: {
|
||||
releaseStartupResourcesOnTimeout = undefined;
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
startupAttemptError = error;
|
||||
throw error;
|
||||
} finally {
|
||||
if (!startupAttemptSucceeded) {
|
||||
if (releaseSharedClientLease === startupClientLease) {
|
||||
releaseSharedClientLease = undefined;
|
||||
}
|
||||
startupClientLease?.();
|
||||
if (startupAbandoned || params.signal.aborted) {
|
||||
if (startupClientForAbandonedRequestCleanup === startupClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
await closeAbandonedStartupClient(startupClient);
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(startupAttemptError) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error: startupAttemptError,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
if (startupClientForAbandonedRequestCleanup === startupClient) {
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
await evictFailedStartupClient(startupClient);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -375,26 +432,115 @@ export async function startCodexAttemptThread(params: {
|
||||
releaseSharedClientLease,
|
||||
};
|
||||
} catch (error) {
|
||||
if (
|
||||
params.signal.aborted ||
|
||||
if (params.signal.aborted || shouldClearSharedClientAfterStartupAbandon(error)) {
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await closeAbandonedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
} else if (
|
||||
shouldClearSharedClientAfterStartupRace(error) ||
|
||||
shouldClearSharedClientAfterStartupFailure({
|
||||
error,
|
||||
spawnedBy: params.spawnedBy,
|
||||
})
|
||||
) {
|
||||
clearSharedCodexAppServerClientIfCurrent(startupClientForAbandonedRequestCleanup);
|
||||
releaseSharedClientLease?.();
|
||||
releaseSharedClientLease = undefined;
|
||||
await evictFailedStartupClient(startupClientForAbandonedRequestCleanup);
|
||||
startupClientForAbandonedRequestCleanup = undefined;
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
params.signal.removeEventListener("abort", abandonStartupAcquire);
|
||||
}
|
||||
}
|
||||
|
||||
async function closeAbandonedStartupClient(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
async function closeClientAndWaitIfAvailable(client: CodexAppServerClient): Promise<void> {
|
||||
const closeable = client as {
|
||||
close?: CodexAppServerClient["close"];
|
||||
closeAndWait?: CodexAppServerClient["closeAndWait"];
|
||||
};
|
||||
if (typeof closeable.closeAndWait === "function") {
|
||||
await closeable.closeAndWait();
|
||||
return;
|
||||
}
|
||||
closeable.close?.();
|
||||
}
|
||||
|
||||
async function evictFailedStartupClient(client: CodexAppServerClient | undefined): Promise<void> {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
const unclaimedSharedClient = clearSharedCodexAppServerClientIfCurrentAndUnclaimed(client);
|
||||
if (unclaimedSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
if (unclaimedSharedClient.found) {
|
||||
const retired = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retired?.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const retiredSharedClient = retireSharedCodexAppServerClientIfCurrent(client);
|
||||
if (retiredSharedClient) {
|
||||
if (retiredSharedClient.closed) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (clearSharedCodexAppServerClientIfCurrent(client)) {
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
return;
|
||||
}
|
||||
await closeClientAndWaitIfAvailable(client);
|
||||
}
|
||||
|
||||
function shouldClearSharedClientAfterStartupAbandon(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === "codex app-server startup timed out" ||
|
||||
error.message === "codex app-server startup aborted")
|
||||
);
|
||||
}
|
||||
|
||||
function shouldClearSharedClientAfterStartupRace(error: unknown): boolean {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === "codex app-server startup timed out" ||
|
||||
error.message === "codex app-server startup aborted" ||
|
||||
error.message.endsWith(" timed out"))
|
||||
(shouldClearSharedClientAfterStartupAbandon(error) || error.message.endsWith(" timed out"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -54,6 +54,34 @@ describe("Codex app-server steering queue", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("batches queued steering after a nonzero debounce while the turn is active", async () => {
|
||||
vi.useFakeTimers();
|
||||
const request = vi.fn(async () => ({ turnId: "turn-1" }));
|
||||
const queue = createCodexSteeringQueue({
|
||||
client: { request } as never,
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
answerPendingUserInput: () => false,
|
||||
signal: new AbortController().signal,
|
||||
});
|
||||
|
||||
const firstQueued = queue.queue("first", { debounceMs: 5 });
|
||||
const secondQueued = queue.queue("second", { debounceMs: 5 });
|
||||
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(5);
|
||||
await Promise.all([firstQueued, secondQueued]);
|
||||
|
||||
expect(request).toHaveBeenCalledWith("turn/steer", {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects queued steering when the run aborts before debounce flush", async () => {
|
||||
const controller = new AbortController();
|
||||
const request = vi.fn(async () => ({ turnId: "turn-1" }));
|
||||
|
||||
@@ -11,6 +11,10 @@ export type CodexAppServerClientFactory = (
|
||||
authProfileId?: string,
|
||||
agentDir?: string,
|
||||
config?: AuthProfileOrderConfig,
|
||||
options?: {
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
},
|
||||
) => Promise<CodexAppServerClient>;
|
||||
|
||||
let sharedClientModulePromise: Promise<typeof import("./shared-client.js")> | null = null;
|
||||
@@ -25,9 +29,17 @@ export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
getSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
}),
|
||||
);
|
||||
|
||||
export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
@@ -35,7 +47,15 @@ export const defaultLeasedCodexAppServerClientFactory: CodexAppServerClientFacto
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getLeasedSharedCodexAppServerClient }) =>
|
||||
getLeasedSharedCodexAppServerClient({ startOptions, authProfileId, agentDir, config }),
|
||||
getLeasedSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
}),
|
||||
);
|
||||
|
||||
@@ -408,9 +408,10 @@ describe("CodexAppServerClient", () => {
|
||||
// Start a pending request so we can verify it gets properly rejected.
|
||||
const pending = harness.client.request("test/method");
|
||||
|
||||
// Simulate the child process closing its pipe — a write to the now-dead
|
||||
// stdin emits an asynchronous EPIPE error on the stream.
|
||||
harness.process.stdin.destroy(Object.assign(new Error("write EPIPE"), { code: "EPIPE" }));
|
||||
// Simulate the child process closing its pipe: stdin emits an asynchronous
|
||||
// EPIPE error before the transport observes a process exit.
|
||||
const pipeError = Object.assign(new Error("write EPIPE"), { code: "EPIPE" });
|
||||
harness.process.stdin.emit("error", pipeError);
|
||||
|
||||
// The pending request must be rejected with the pipe error rather than
|
||||
// an unhandled exception tearing down the gateway.
|
||||
|
||||
@@ -4,13 +4,14 @@ import {
|
||||
type EmbeddedAgentCompactResult,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
defaultCodexAppServerClientFactory,
|
||||
defaultLeasedCodexAppServerClientFactory,
|
||||
type CodexAppServerClientFactory,
|
||||
} from "./client-factory.js";
|
||||
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
|
||||
import type { JsonObject } from "./protocol.js";
|
||||
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
|
||||
import { readCodexAppServerBinding } from "./session-binding.js";
|
||||
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
|
||||
|
||||
const warnedIgnoredCompactionOverrides = new Set<string>();
|
||||
|
||||
@@ -177,7 +178,8 @@ async function compactCodexNativeThread(
|
||||
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
|
||||
}
|
||||
|
||||
const clientFactory = options.clientFactory ?? defaultCodexAppServerClientFactory;
|
||||
const shouldReleaseDefaultLease = !options.clientFactory;
|
||||
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
|
||||
const client = await clientFactory(
|
||||
appServer.start,
|
||||
requestedAuthProfileId ?? binding.authProfileId,
|
||||
@@ -211,6 +213,10 @@ async function compactCodexNativeThread(
|
||||
compacted: false,
|
||||
reason: formatCompactionError(error),
|
||||
};
|
||||
} finally {
|
||||
if (shouldReleaseDefaultLease) {
|
||||
releaseLeasedSharedCodexAppServerClient(client);
|
||||
}
|
||||
}
|
||||
const resultDetails: JsonObject = {
|
||||
backend: "codex-app-server",
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import {
|
||||
embeddedAgentLog,
|
||||
type EmbeddedRunAttemptParams,
|
||||
} from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
addSandboxShellDynamicToolsIfAvailable,
|
||||
@@ -223,6 +226,38 @@ describe("Codex app-server dynamic tool build", () => {
|
||||
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
|
||||
});
|
||||
|
||||
it("quarantines non-object plugin schemas before Codex-specific filtering", async () => {
|
||||
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
|
||||
const messageTool = createRuntimeDynamicTool("message");
|
||||
const brokenTool = {
|
||||
...createRuntimeDynamicTool("dofbot_move_angles"),
|
||||
parameters: { type: "array", items: { type: "number" } },
|
||||
};
|
||||
setOpenClawCodingToolsFactoryForTests(() => [brokenTool, messageTool]);
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const workspaceDir = path.join(tempDir, "workspace");
|
||||
const params = createParams(sessionFile, workspaceDir);
|
||||
params.disableTools = false;
|
||||
params.runtimePlan = createCodexRuntimePlanFixture();
|
||||
|
||||
await expect(buildDynamicToolsForTest(params, workspaceDir)).resolves.toEqual([messageTool]);
|
||||
expect(warn).toHaveBeenCalledWith(
|
||||
"codex app-server quarantined 1 unsupported runtime tool schema before dynamic tool registration",
|
||||
expect.objectContaining({
|
||||
runId: "run-1",
|
||||
sessionId: "session-1",
|
||||
diagnostics: [
|
||||
{
|
||||
index: 0,
|
||||
tool: "dofbot_move_angles",
|
||||
violations: ['dofbot_move_angles.parameters.type must be "object"'],
|
||||
violationCount: 1,
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("limits Codex memory flush runs to managed read and write tools", async () => {
|
||||
const factoryOptions: unknown[] = [];
|
||||
setOpenClawCodingToolsFactoryForTests((options) => {
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import path from "node:path";
|
||||
import { abortAgentHarnessRun } from "openclaw/plugin-sdk/agent-harness-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "../../prompt-overlay.js";
|
||||
import type { CodexServerNotification } from "./protocol.js";
|
||||
@@ -18,48 +17,52 @@ import {
|
||||
|
||||
setupRunAttemptTestHooks();
|
||||
|
||||
function createSteeringParams(name: string) {
|
||||
let steeringSessionIndex = 0;
|
||||
|
||||
function createSteeringParams() {
|
||||
const sessionId = `steering-session-${++steeringSessionIndex}`;
|
||||
const params = createParams(
|
||||
path.join(tempDir, `${name}.jsonl`),
|
||||
path.join(tempDir, `${name}-workspace`),
|
||||
path.join(tempDir, `${sessionId}.jsonl`),
|
||||
path.join(tempDir, `${sessionId}-workspace`),
|
||||
);
|
||||
params.sessionId = `session-${name}`;
|
||||
params.sessionKey = `agent:main:session-${name}`;
|
||||
params.sessionId = sessionId;
|
||||
params.sessionKey = `agent:main:${sessionId}`;
|
||||
params.runId = `run-${sessionId}`;
|
||||
return params;
|
||||
}
|
||||
|
||||
async function queueActiveRunMessageEventually(
|
||||
async function waitAndQueueActiveRunMessage(
|
||||
sessionId: string,
|
||||
text: string,
|
||||
options?: Parameters<typeof queueActiveRunMessageForTest>[2],
|
||||
) {
|
||||
await vi.waitFor(
|
||||
() => expect(queueActiveRunMessageForTest(sessionId, text, options)).toBe(true),
|
||||
fastWait,
|
||||
);
|
||||
let queued = false;
|
||||
await vi.waitFor(() => {
|
||||
if (!queued) {
|
||||
queued = queueActiveRunMessageForTest(sessionId, text, options);
|
||||
}
|
||||
expect(queued).toBe(true);
|
||||
}, fastWait);
|
||||
}
|
||||
|
||||
describe("runCodexAppServerAttempt steering", () => {
|
||||
it("forwards queued user input and aborts the active app-server turn", async () => {
|
||||
const { requests, waitForMethod } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-forward");
|
||||
it("forwards queued user input to the active app-server turn", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams();
|
||||
|
||||
const run = runCodexAppServerAttempt(params, { pluginConfig: { appServer: { mode: "yolo" } } });
|
||||
const run = runCodexAppServerAttempt(params, {
|
||||
pluginConfig: { appServer: { mode: "yolo" } },
|
||||
});
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await queueActiveRunMessageEventually(params.sessionId, "more context", { debounceMs: 1 });
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "more context", { debounceMs: 0 });
|
||||
await vi.waitFor(
|
||||
() => expect(requests.map((entry) => entry.method)).toContain("turn/steer"),
|
||||
fastWait,
|
||||
);
|
||||
expect(abortAgentHarnessRun(params.sessionId)).toBe(true);
|
||||
await vi.waitFor(
|
||||
() => expect(requests.map((entry) => entry.method)).toContain("turn/interrupt"),
|
||||
fastWait,
|
||||
);
|
||||
|
||||
const result = await run;
|
||||
expect(result.aborted).toBe(true);
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
const threadStart = requests.find((entry) => entry.method === "thread/start");
|
||||
const threadStartParams = threadStart?.params as
|
||||
| {
|
||||
@@ -81,27 +84,21 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
expectedTurnId: "turn-1",
|
||||
input: [{ type: "text", text: "more context", text_elements: [] }],
|
||||
});
|
||||
const interrupt = requests.find((entry) => entry.method === "turn/interrupt");
|
||||
expect(interrupt?.params).toEqual({ threadId: "thread-1", turnId: "turn-1" });
|
||||
});
|
||||
|
||||
it("accepts message-tool-only steering for active Codex app-server source replies", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-message-tool");
|
||||
const params = createSteeringParams();
|
||||
params.sourceReplyDeliveryMode = "message_tool_only";
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await queueActiveRunMessageEventually(
|
||||
params.sessionId,
|
||||
"subagent complete",
|
||||
{
|
||||
debounceMs: 1,
|
||||
steeringMode: "all",
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
},
|
||||
);
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "subagent complete", {
|
||||
debounceMs: 0,
|
||||
steeringMode: "all",
|
||||
sourceReplyDeliveryMode: "message_tool_only",
|
||||
});
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
@@ -115,53 +112,51 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
},
|
||||
},
|
||||
]),
|
||||
fastWait,
|
||||
{ interval: 1 },
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
});
|
||||
|
||||
it("batches default queued steering before sending turn/steer", async () => {
|
||||
it("flushes batched default queued steering during normal turn cleanup", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-batch-default");
|
||||
const params = createSteeringParams();
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await queueActiveRunMessageEventually(params.sessionId, "first", { debounceMs: 5 });
|
||||
expect(queueActiveRunMessageForTest(params.sessionId, "second", { debounceMs: 5 })).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
|
||||
{
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
fastWait,
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "first", { debounceMs: 30_000 });
|
||||
expect(queueActiveRunMessageForTest(params.sessionId, "second", { debounceMs: 30_000 })).toBe(
|
||||
true,
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
|
||||
{
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("flushes pending default queued steering during normal turn cleanup", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-flush");
|
||||
const params = createSteeringParams();
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await queueActiveRunMessageEventually(params.sessionId, "late steer", { debounceMs: 30_000 });
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "late steer", { debounceMs: 30_000 });
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
@@ -178,44 +173,40 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("batches explicit all-mode steering before sending turn/steer", async () => {
|
||||
it("flushes batched explicit all-mode steering during normal turn cleanup", async () => {
|
||||
const { requests, waitForMethod, completeTurn } = createStartedThreadHarness();
|
||||
const params = createSteeringParams("steering-batch-all");
|
||||
const params = createSteeringParams();
|
||||
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await waitForMethod("turn/start");
|
||||
|
||||
await queueActiveRunMessageEventually(params.sessionId, "first", {
|
||||
debounceMs: 5,
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "first", {
|
||||
debounceMs: 30_000,
|
||||
steeringMode: "all",
|
||||
});
|
||||
expect(
|
||||
queueActiveRunMessageForTest(params.sessionId, "second", {
|
||||
debounceMs: 5,
|
||||
debounceMs: 30_000,
|
||||
steeringMode: "all",
|
||||
}),
|
||||
).toBe(true);
|
||||
|
||||
await vi.waitFor(
|
||||
() =>
|
||||
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
|
||||
{
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
]),
|
||||
fastWait,
|
||||
);
|
||||
|
||||
await completeTurn({ threadId: "thread-1", turnId: "turn-1" });
|
||||
await run;
|
||||
|
||||
expect(requests.filter((entry) => entry.method === "turn/steer")).toEqual([
|
||||
{
|
||||
method: "turn/steer",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
expectedTurnId: "turn-1",
|
||||
input: [
|
||||
{ type: "text", text: "first", text_elements: [] },
|
||||
{ type: "text", text: "second", text_elements: [] },
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it("routes request_user_input prompts through the active run follow-up queue", async () => {
|
||||
@@ -253,7 +244,7 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
}) as never,
|
||||
);
|
||||
|
||||
const params = createSteeringParams("steering-request-input");
|
||||
const params = createSteeringParams();
|
||||
params.onBlockReply = vi.fn();
|
||||
const run = runCodexAppServerAttempt(params);
|
||||
await vi.waitFor(
|
||||
@@ -286,7 +277,7 @@ describe("runCodexAppServerAttempt steering", () => {
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(params.onBlockReply).toHaveBeenCalledTimes(1), fastWait);
|
||||
await queueActiveRunMessageEventually(params.sessionId, "2");
|
||||
await waitAndQueueActiveRunMessage(params.sessionId, "2");
|
||||
await expect(response).resolves.toEqual({
|
||||
answers: { mode: { answers: ["Deep"] } },
|
||||
});
|
||||
|
||||
@@ -189,6 +189,28 @@ describe("shared Codex app-server client", () => {
|
||||
expect(startSpy).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps a pending shared app-server alive when another acquire still owns startup", async () => {
|
||||
const harness = createClientHarness();
|
||||
const abandonController = new AbortController();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
const abandonedAcquire = getSharedCodexAppServerClient({
|
||||
timeoutMs: 1000,
|
||||
abandonSignal: abandonController.signal,
|
||||
});
|
||||
const activeAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
|
||||
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
|
||||
|
||||
abandonController.abort();
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
|
||||
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
|
||||
|
||||
await expect(abandonedAcquire).resolves.toBe(harness.client);
|
||||
await expect(activeAcquire).resolves.toBe(harness.client);
|
||||
expect(harness.process.stdin.destroyed).toBe(false);
|
||||
});
|
||||
|
||||
it("does not wait for isolated initialize after a timeout closes the client", async () => {
|
||||
const harness = createClientHarness();
|
||||
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
|
||||
|
||||
@@ -18,6 +18,7 @@ type SharedCodexAppServerClientEntry = {
|
||||
client?: CodexAppServerClient;
|
||||
promise?: Promise<CodexAppServerClient>;
|
||||
activeLeases: number;
|
||||
pendingAcquires: number;
|
||||
closeWhenIdle: boolean;
|
||||
};
|
||||
|
||||
@@ -48,6 +49,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
const clients = keyedState.clients as Map<string, SharedCodexAppServerClientEntry>;
|
||||
for (const entry of clients.values()) {
|
||||
entry.activeLeases ??= 0;
|
||||
entry.pendingAcquires ??= 0;
|
||||
entry.closeWhenIdle ??= false;
|
||||
}
|
||||
const nextState: SharedCodexAppServerClientState = {
|
||||
@@ -66,6 +68,7 @@ function getSharedCodexAppServerClientState(): SharedCodexAppServerClientState {
|
||||
client: legacyState.client,
|
||||
promise: legacyState.promise,
|
||||
activeLeases: 0,
|
||||
pendingAcquires: 0,
|
||||
closeWhenIdle: false,
|
||||
});
|
||||
legacyState.client?.addCloseHandler((closedClient) =>
|
||||
@@ -102,6 +105,8 @@ type CodexAppServerClientOptions = {
|
||||
authProfileId?: string | null;
|
||||
agentDir?: string;
|
||||
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
|
||||
onStartedClient?: (client: CodexAppServerClient) => void;
|
||||
abandonSignal?: AbortSignal;
|
||||
};
|
||||
|
||||
type ResolvedCodexAppServerClientStartContext = {
|
||||
@@ -194,11 +199,27 @@ async function acquireSharedCodexAppServerClient(
|
||||
});
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
const entry = getOrCreateSharedClientEntry(state, key);
|
||||
const releasePendingAcquire = retainPendingSharedClientAcquire(entry);
|
||||
let cleanupAbandonSignal: (() => void) | undefined;
|
||||
if (options?.abandonSignal) {
|
||||
const abandon = () => {
|
||||
// Release this acquire before cleanup checks ownership; only other
|
||||
// pending callers should keep the startup client alive.
|
||||
releasePendingAcquire();
|
||||
closeSharedClientEntryIfUnclaimed(key, entry);
|
||||
};
|
||||
options.abandonSignal.addEventListener("abort", abandon, { once: true });
|
||||
cleanupAbandonSignal = () => options.abandonSignal?.removeEventListener("abort", abandon);
|
||||
if (options.abandonSignal.aborted) {
|
||||
abandon();
|
||||
}
|
||||
}
|
||||
const sharedPromise =
|
||||
entry.promise ??
|
||||
(entry.promise = (async () => {
|
||||
const client = CodexAppServerClient.start(startOptions);
|
||||
entry.client = client;
|
||||
options?.onStartedClient?.(client);
|
||||
client.setActiveSharedLeaseCountProviderForUnscopedNotifications(() => entry.activeLeases);
|
||||
client.addCloseHandler((closedClient) => clearSharedClientEntryIfCurrent(key, closedClient));
|
||||
try {
|
||||
@@ -233,6 +254,9 @@ async function acquireSharedCodexAppServerClient(
|
||||
clearSharedClientEntry(key, currentEntry);
|
||||
}
|
||||
throw error;
|
||||
} finally {
|
||||
cleanupAbandonSignal?.();
|
||||
releasePendingAcquire();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -386,7 +410,7 @@ function getOrCreateSharedClientEntry(
|
||||
): SharedCodexAppServerClientEntry {
|
||||
let entry = state.clients.get(key);
|
||||
if (!entry) {
|
||||
entry = { activeLeases: 0, closeWhenIdle: false };
|
||||
entry = { activeLeases: 0, pendingAcquires: 0, closeWhenIdle: false };
|
||||
state.clients.set(key, entry);
|
||||
}
|
||||
return entry;
|
||||
@@ -409,6 +433,39 @@ function clearSharedClientEntryIfCurrent(key: string, client: CodexAppServerClie
|
||||
}
|
||||
}
|
||||
|
||||
export function clearSharedCodexAppServerClientIfCurrentAndUnclaimed(
|
||||
client: CodexAppServerClient | undefined,
|
||||
): { found: boolean; closed: boolean; activeLeases: number; pendingAcquires: number } {
|
||||
if (!client) {
|
||||
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
for (const [key, entry] of state.clients) {
|
||||
if (entry.client === client) {
|
||||
return {
|
||||
found: true,
|
||||
closed: closeSharedClientEntryIfUnclaimed(key, entry),
|
||||
activeLeases: entry.activeLeases,
|
||||
pendingAcquires: entry.pendingAcquires,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { found: false, closed: false, activeLeases: 0, pendingAcquires: 0 };
|
||||
}
|
||||
|
||||
function retainPendingSharedClientAcquire(entry: SharedCodexAppServerClientEntry): () => void {
|
||||
let released = false;
|
||||
entry.pendingAcquires += 1;
|
||||
return () => {
|
||||
if (released) {
|
||||
return;
|
||||
}
|
||||
released = true;
|
||||
entry.pendingAcquires = Math.max(0, entry.pendingAcquires - 1);
|
||||
closeRetiredSharedClientEntryIfIdle(entry);
|
||||
};
|
||||
}
|
||||
|
||||
function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () => void {
|
||||
let released = false;
|
||||
entry.activeLeases += 1;
|
||||
@@ -423,7 +480,12 @@ function retainSharedClientEntry(entry: SharedCodexAppServerClientEntry): () =>
|
||||
}
|
||||
|
||||
function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEntry): boolean {
|
||||
if (!entry.closeWhenIdle || entry.activeLeases > 0 || !entry.client) {
|
||||
if (
|
||||
!entry.closeWhenIdle ||
|
||||
entry.activeLeases > 0 ||
|
||||
entry.pendingAcquires > 0 ||
|
||||
!entry.client
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const client = entry.client;
|
||||
@@ -433,6 +495,22 @@ function closeRetiredSharedClientEntryIfIdle(entry: SharedCodexAppServerClientEn
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeSharedClientEntryIfUnclaimed(
|
||||
key: string,
|
||||
entry: SharedCodexAppServerClientEntry,
|
||||
): boolean {
|
||||
if (entry.activeLeases > 0 || entry.pendingAcquires > 0) {
|
||||
return false;
|
||||
}
|
||||
const state = getSharedCodexAppServerClientState();
|
||||
if (state.clients.get(key) !== entry) {
|
||||
return false;
|
||||
}
|
||||
state.clients.delete(key);
|
||||
entry.client?.close();
|
||||
return Boolean(entry.client);
|
||||
}
|
||||
|
||||
function collectSharedClients(state: SharedCodexAppServerClientState): CodexAppServerClient[] {
|
||||
return [
|
||||
...new Set(
|
||||
|
||||
@@ -22,6 +22,15 @@ export function createClientHarness() {
|
||||
const stdout = new PassThrough();
|
||||
const writes: string[] = [];
|
||||
let stdinDestroyed = false;
|
||||
let exitEmitted = false;
|
||||
let emitProcessExit: () => void = () => undefined;
|
||||
type HarnessProcess = EventEmitter & {
|
||||
stdin: Writable;
|
||||
stdout: PassThrough;
|
||||
stderr: PassThrough;
|
||||
killed: boolean;
|
||||
kill: (signal?: NodeJS.Signals) => unknown;
|
||||
};
|
||||
const stdin = new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
writes.push(chunk.toString());
|
||||
@@ -31,17 +40,27 @@ export function createClientHarness() {
|
||||
const destroyStdin = stdin.destroy.bind(stdin);
|
||||
stdin.destroy = ((error?: Error) => {
|
||||
stdinDestroyed = true;
|
||||
return destroyStdin(error);
|
||||
const result = destroyStdin(error);
|
||||
if (!exitEmitted) {
|
||||
exitEmitted = true;
|
||||
// Let stdin surface pipe errors before the harness emits the fake child exit.
|
||||
// Otherwise close-reason tests can race EPIPE against a synthetic clean exit.
|
||||
setImmediate(emitProcessExit);
|
||||
}
|
||||
return result;
|
||||
}) as typeof stdin.destroy;
|
||||
const process = Object.assign(new EventEmitter(), {
|
||||
const process: HarnessProcess = Object.assign(new EventEmitter(), {
|
||||
stdin,
|
||||
stdout,
|
||||
stderr: new PassThrough(),
|
||||
killed: false,
|
||||
kill: vi.fn(() => {
|
||||
kill: vi.fn((_signal?: NodeJS.Signals) => {
|
||||
process.killed = true;
|
||||
}),
|
||||
});
|
||||
emitProcessExit = () => {
|
||||
process.emit("exit", 0, null);
|
||||
};
|
||||
const client = CodexAppServerClient.fromTransportForTests(process);
|
||||
return {
|
||||
client,
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { createCodexConversationTurnCollector } from "./conversation-turn-collector.js";
|
||||
|
||||
describe("codex conversation turn collector", () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("collects streamed assistant deltas for the active turn", async () => {
|
||||
const collector = createCodexConversationTurnCollector("thread-1");
|
||||
collector.setTurnId("turn-1");
|
||||
@@ -192,9 +197,8 @@ describe("codex conversation turn collector", () => {
|
||||
});
|
||||
|
||||
it("clamps oversized turn wait timers", async () => {
|
||||
vi.useFakeTimers();
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
try {
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
|
||||
const collector = createCodexConversationTurnCollector("thread-1");
|
||||
collector.setTurnId("turn-1");
|
||||
const completion = collector.wait({ timeoutMs: MAX_TIMER_TIMEOUT_MS + 1 });
|
||||
|
||||
@@ -1,15 +1,8 @@
|
||||
import { EmbeddedBlockChunker, formatReasoningMessage } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import { EmbeddedBlockChunker } from "openclaw/plugin-sdk/agent-runtime";
|
||||
import {
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
mergeChannelProgressDraftLine,
|
||||
normalizeChannelProgressDraftLineIdentity,
|
||||
resolveChannelProgressDraftMaxLineChars,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
createChannelProgressDraftCompositor,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingProgressCommentary,
|
||||
resolveChannelStreamingPreviewToolProgress,
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
@@ -79,86 +72,48 @@ export function createDiscordDraftPreviewController(params: {
|
||||
let draftText = "";
|
||||
let hasStreamedMessage = false;
|
||||
let finalizedViaPreviewMessage = false;
|
||||
let finalReplyStarted = false;
|
||||
let finalReplyDelivered = false;
|
||||
const previewToolProgressEnabled =
|
||||
Boolean(draftStream) && resolveChannelStreamingPreviewToolProgress(params.discordConfig);
|
||||
const commentaryProgressEnabled =
|
||||
Boolean(draftStream) && resolveChannelStreamingProgressCommentary(params.discordConfig);
|
||||
const suppressDefaultToolProgressMessages =
|
||||
Boolean(draftStream) &&
|
||||
resolveChannelStreamingSuppressDefaultToolProgressMessages(params.discordConfig, {
|
||||
draftStreamActive: true,
|
||||
previewToolProgressEnabled,
|
||||
});
|
||||
let previewToolProgressSuppressed = false;
|
||||
let previewToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
|
||||
let reasoningProgressRawText = "";
|
||||
let lastReasoningProgressLine: string | undefined;
|
||||
const progressSeed = `${params.accountId}:${params.deliverChannelId}`;
|
||||
|
||||
const renderProgressDraft = async (options?: { flush?: boolean }) => {
|
||||
if (!draftStream || discordStreamMode !== "progress") {
|
||||
return;
|
||||
}
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: params.discordConfig,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
if (!previewText || previewText === lastPartialText) {
|
||||
return;
|
||||
}
|
||||
lastPartialText = previewText;
|
||||
draftText = previewText;
|
||||
hasStreamedMessage = true;
|
||||
draftChunker?.reset();
|
||||
draftStream.update(previewText);
|
||||
if (options?.flush) {
|
||||
await draftStream.flush();
|
||||
}
|
||||
};
|
||||
|
||||
const progressDraftGate = createChannelProgressDraftGate({
|
||||
onStart: () => renderProgressDraft({ flush: true }),
|
||||
const progressDraft = createChannelProgressDraftCompositor({
|
||||
entry: params.discordConfig,
|
||||
mode: discordStreamMode,
|
||||
active: Boolean(draftStream),
|
||||
seed: progressSeed,
|
||||
update: async (previewText, options) => {
|
||||
lastPartialText = previewText;
|
||||
draftText = previewText;
|
||||
hasStreamedMessage = true;
|
||||
draftChunker?.reset();
|
||||
draftStream?.update(previewText);
|
||||
if (options?.flush) {
|
||||
await draftStream?.flush();
|
||||
}
|
||||
},
|
||||
deleteCurrent: async () => {
|
||||
lastPartialText = "";
|
||||
draftText = "";
|
||||
hasStreamedMessage = false;
|
||||
if (draftStream?.messageId()) {
|
||||
await draftStream.deleteCurrentMessage();
|
||||
}
|
||||
},
|
||||
isEmptyLine: isEmptyDiscordProgressLine,
|
||||
shouldStartNow: shouldStartDiscordProgressDraftNow,
|
||||
});
|
||||
|
||||
const clearProgressDraftLine = async (lineId: string) => {
|
||||
const nextLines = previewToolProgressLines.filter(
|
||||
(line) => typeof line !== "object" || line.id?.trim() !== lineId,
|
||||
);
|
||||
if (nextLines.length === previewToolProgressLines.length) {
|
||||
return;
|
||||
}
|
||||
previewToolProgressLines = nextLines;
|
||||
if (!progressDraftGate.hasStarted) {
|
||||
return;
|
||||
}
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: params.discordConfig,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
if (previewText) {
|
||||
await renderProgressDraft();
|
||||
return;
|
||||
}
|
||||
lastPartialText = "";
|
||||
draftText = "";
|
||||
hasStreamedMessage = false;
|
||||
if (draftStream?.messageId()) {
|
||||
await draftStream.deleteCurrentMessage();
|
||||
}
|
||||
};
|
||||
|
||||
const resetProgressState = () => {
|
||||
lastPartialText = "";
|
||||
draftText = "";
|
||||
draftChunker?.reset();
|
||||
previewToolProgressSuppressed = false;
|
||||
previewToolProgressLines = [];
|
||||
reasoningProgressRawText = "";
|
||||
lastReasoningProgressLine = undefined;
|
||||
progressDraft.reset();
|
||||
};
|
||||
|
||||
const forceNewMessageIfNeeded = () => {
|
||||
@@ -172,22 +127,23 @@ export function createDiscordDraftPreviewController(params: {
|
||||
return {
|
||||
draftStream,
|
||||
previewToolProgressEnabled,
|
||||
commentaryProgressEnabled,
|
||||
commentaryProgressEnabled: progressDraft.commentaryProgressEnabled,
|
||||
suppressDefaultToolProgressMessages,
|
||||
get isProgressMode() {
|
||||
return discordStreamMode === "progress";
|
||||
},
|
||||
get hasProgressDraftStarted() {
|
||||
return progressDraftGate.hasStarted;
|
||||
return progressDraft.hasStarted;
|
||||
},
|
||||
get finalizedViaPreviewMessage() {
|
||||
return finalizedViaPreviewMessage;
|
||||
},
|
||||
markFinalReplyStarted() {
|
||||
finalReplyStarted = true;
|
||||
progressDraft.markFinalReplyStarted();
|
||||
},
|
||||
markFinalReplyDelivered() {
|
||||
finalReplyDelivered = true;
|
||||
progressDraft.markFinalReplyDelivered();
|
||||
},
|
||||
markPreviewFinalized() {
|
||||
finalizedViaPreviewMessage = true;
|
||||
@@ -197,149 +153,19 @@ export function createDiscordDraftPreviewController(params: {
|
||||
if (!draftStream || discordStreamMode !== "progress") {
|
||||
return;
|
||||
}
|
||||
await progressDraftGate.startNow();
|
||||
await progressDraft.start();
|
||||
},
|
||||
async pushToolProgress(
|
||||
line?: string | ChannelProgressDraftLine,
|
||||
options?: { toolName?: string },
|
||||
) {
|
||||
if (!draftStream) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyStarted || finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
options?.toolName !== undefined &&
|
||||
!isChannelProgressDraftWorkToolName(options.toolName)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (isEmptyDiscordProgressLine(line)) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeChannelProgressDraftLineIdentity(line);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const progressLine: string | ChannelProgressDraftLine =
|
||||
typeof line === "object" && line !== undefined ? line : normalized;
|
||||
if (discordStreamMode !== "progress") {
|
||||
if (!previewToolProgressEnabled || previewToolProgressSuppressed) {
|
||||
return;
|
||||
}
|
||||
const nextLines = mergeChannelProgressDraftLine(previewToolProgressLines, progressLine, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
});
|
||||
if (nextLines === previewToolProgressLines) {
|
||||
return;
|
||||
}
|
||||
previewToolProgressLines = nextLines;
|
||||
const previewText = formatChannelProgressDraftText({
|
||||
entry: params.discordConfig,
|
||||
lines: previewToolProgressLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
lastPartialText = previewText;
|
||||
draftText = previewText;
|
||||
hasStreamedMessage = true;
|
||||
draftChunker?.reset();
|
||||
draftStream.update(previewText);
|
||||
return;
|
||||
}
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed && normalized) {
|
||||
previewToolProgressLines = mergeChannelProgressDraftLine(
|
||||
previewToolProgressLines,
|
||||
progressLine,
|
||||
{
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
},
|
||||
);
|
||||
}
|
||||
const alreadyStarted = progressDraftGate.hasStarted;
|
||||
let progressActive;
|
||||
if (shouldStartDiscordProgressDraftNow(line)) {
|
||||
await progressDraftGate.startNow();
|
||||
progressActive = progressDraftGate.hasStarted;
|
||||
} else {
|
||||
progressActive = await progressDraftGate.noteWork();
|
||||
}
|
||||
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
}
|
||||
await progressDraft.pushToolProgress(line, options);
|
||||
},
|
||||
async pushReasoningProgress(text?: string, options?: { snapshot?: boolean }) {
|
||||
if (!draftStream || discordStreamMode !== "progress" || !text) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
reasoningProgressRawText = mergeReasoningProgressText(reasoningProgressRawText, text, {
|
||||
snapshot: options?.snapshot === true,
|
||||
});
|
||||
const normalized = normalizeReasoningProgressLine(reasoningProgressRawText);
|
||||
if (!normalized) {
|
||||
return;
|
||||
}
|
||||
const displayLine = formatReasoningProgressDisplayLine(
|
||||
normalized,
|
||||
resolveChannelProgressDraftMaxLineChars(params.discordConfig),
|
||||
);
|
||||
if (!displayLine) {
|
||||
return;
|
||||
}
|
||||
if (previewToolProgressEnabled && !previewToolProgressSuppressed) {
|
||||
const priorIndex =
|
||||
lastReasoningProgressLine === undefined
|
||||
? -1
|
||||
: previewToolProgressLines.lastIndexOf(lastReasoningProgressLine);
|
||||
if (priorIndex >= 0) {
|
||||
previewToolProgressLines = [...previewToolProgressLines];
|
||||
previewToolProgressLines[priorIndex] = displayLine;
|
||||
} else {
|
||||
previewToolProgressLines = [...previewToolProgressLines, displayLine].slice(
|
||||
-resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
);
|
||||
}
|
||||
lastReasoningProgressLine = displayLine;
|
||||
}
|
||||
const progressActive = await progressDraftGate.noteWork();
|
||||
if (progressActive && progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
}
|
||||
await progressDraft.pushReasoningProgress(text, options);
|
||||
},
|
||||
async pushCommentaryProgress(text?: string, options?: { itemId?: string }) {
|
||||
if (!draftStream || discordStreamMode !== "progress" || !commentaryProgressEnabled) {
|
||||
return;
|
||||
}
|
||||
if (finalReplyStarted || finalReplyDelivered) {
|
||||
return;
|
||||
}
|
||||
const itemId = options?.itemId?.trim();
|
||||
if (!text && !itemId) {
|
||||
return;
|
||||
}
|
||||
const normalized = normalizeCommentaryProgressText(text ?? "");
|
||||
const lineId = itemId ? `commentary:${itemId}` : normalized ? `commentary:${normalized}` : "";
|
||||
if (!normalized) {
|
||||
if (lineId) {
|
||||
await clearProgressDraftLine(lineId);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const line: ChannelProgressDraftLine = {
|
||||
id: lineId,
|
||||
kind: "item",
|
||||
text: normalized,
|
||||
label: "Commentary",
|
||||
prefix: false,
|
||||
};
|
||||
previewToolProgressLines = mergeChannelProgressDraftLine(previewToolProgressLines, line, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(params.discordConfig),
|
||||
});
|
||||
await progressDraftGate.startNow();
|
||||
await renderProgressDraft();
|
||||
await progressDraft.pushCommentaryProgress(text, options);
|
||||
},
|
||||
resolvePreviewFinalText(text?: string) {
|
||||
if (typeof text !== "string") {
|
||||
@@ -390,8 +216,7 @@ export function createDiscordDraftPreviewController(params: {
|
||||
if (discordStreamMode === "progress") {
|
||||
return;
|
||||
}
|
||||
previewToolProgressSuppressed = true;
|
||||
previewToolProgressLines = [];
|
||||
progressDraft.suppress();
|
||||
hasStreamedMessage = true;
|
||||
if (discordStreamMode === "partial") {
|
||||
if (
|
||||
@@ -457,7 +282,7 @@ export function createDiscordDraftPreviewController(params: {
|
||||
},
|
||||
async cleanup() {
|
||||
try {
|
||||
progressDraftGate.cancel();
|
||||
progressDraft.cancel();
|
||||
if (!finalReplyDelivered) {
|
||||
await draftStream?.discardPending();
|
||||
}
|
||||
@@ -471,106 +296,6 @@ export function createDiscordDraftPreviewController(params: {
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressLine(text: string): string {
|
||||
return text
|
||||
.replace(
|
||||
/^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i,
|
||||
"",
|
||||
)
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeReasoningProgressInput(text: string): string {
|
||||
const normalized = normalizeReasoningProgressLine(text);
|
||||
const italic = normalized.match(/^_(.*)_$/u);
|
||||
return (italic?.[1] ?? normalized).trim();
|
||||
}
|
||||
|
||||
function formatReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalizedText = normalizeReasoningProgressInput(text);
|
||||
const formatted = normalizeReasoningProgressLine(formatReasoningMessage(normalizedText));
|
||||
if (!formatted) {
|
||||
return "";
|
||||
}
|
||||
if (Array.from(formatted).length <= maxChars) {
|
||||
return formatted;
|
||||
}
|
||||
const italic = formatted.match(/^_(.*)_$/u);
|
||||
if (!italic) {
|
||||
return compactReasoningProgressDisplayLine(formatted, maxChars);
|
||||
}
|
||||
const body = compactReasoningProgressDisplayLine(italic[1] ?? "", Math.max(1, maxChars - 2));
|
||||
return body ? `_${body}_` : "";
|
||||
}
|
||||
|
||||
function compactReasoningProgressDisplayLine(text: string, maxChars: number): string {
|
||||
const normalized = text.replace(/\s+/g, " ").trim();
|
||||
const chars = Array.from(normalized);
|
||||
if (chars.length <= maxChars) {
|
||||
return normalized;
|
||||
}
|
||||
if (maxChars <= 1) {
|
||||
return "…";
|
||||
}
|
||||
const head = chars
|
||||
.slice(0, maxChars - 1)
|
||||
.join("")
|
||||
.trimEnd();
|
||||
const boundary = head.search(/\s+\S*$/u);
|
||||
if (boundary > Math.floor(maxChars * 0.6)) {
|
||||
return `${head.slice(0, boundary).trimEnd()}…`;
|
||||
}
|
||||
return `${head}…`;
|
||||
}
|
||||
|
||||
function normalizeCommentaryProgressText(text: string): string {
|
||||
const cleaned = stripInlineDirectiveTagsForDelivery(text).text.trim();
|
||||
if (!cleaned || isSilentCommentaryProgressText(cleaned)) {
|
||||
return "";
|
||||
}
|
||||
return cleaned
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.replace(/\s+/g, " ").trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => `_${line}_`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function isSilentCommentaryProgressText(text: string): boolean {
|
||||
const normalized = text.replace(/^[\s*_`~]+|[\s*_`~]+$/gu, "").trim();
|
||||
return /^NO_REPLY$/iu.test(normalized);
|
||||
}
|
||||
|
||||
function mergeReasoningProgressText(
|
||||
current: string,
|
||||
incoming: string,
|
||||
options?: { snapshot?: boolean },
|
||||
): string {
|
||||
if (!current) {
|
||||
return incoming;
|
||||
}
|
||||
const normalizedCurrent = normalizeReasoningProgressLine(current);
|
||||
const normalizedIncoming = normalizeReasoningProgressLine(incoming);
|
||||
if (!normalizedIncoming || normalizedIncoming === normalizedCurrent) {
|
||||
return current;
|
||||
}
|
||||
if (
|
||||
options?.snapshot === true ||
|
||||
isReasoningSnapshotText(incoming) ||
|
||||
normalizedIncoming.startsWith(normalizedCurrent)
|
||||
) {
|
||||
return incoming;
|
||||
}
|
||||
return `${current}${incoming}`;
|
||||
}
|
||||
|
||||
function isReasoningSnapshotText(text: string): boolean {
|
||||
return /^\s*(?:>\s*)?(?:Reasoning:\s*(?:\r?\n|\r)\s*|Thinking\.{0,3}\s*(?:\r?\n|\r)\s*(?:\r?\n|\r)\s*)/i.test(
|
||||
text,
|
||||
);
|
||||
}
|
||||
|
||||
function isEmptyDiscordProgressLine(line: string | ChannelProgressDraftLine | undefined): boolean {
|
||||
if (!line || typeof line === "string") {
|
||||
return false;
|
||||
|
||||
@@ -2504,7 +2504,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers tool warning finals when no recovered reply is available", async () => {
|
||||
it("suppresses pure tool warning finals when no recovered reply is available", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply(createNonTerminalToolWarningPayload());
|
||||
@@ -2519,18 +2519,10 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
expect(editMessageDiscord).not.toHaveBeenCalled();
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("delivers tool warning finals when the recovered reply fails to send", async () => {
|
||||
it("suppresses tool warning finals when the recovered reply fails to send", async () => {
|
||||
deliverDiscordReply.mockRejectedValueOnce(new Error("send failed"));
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({ text: "delivery failed" });
|
||||
@@ -2549,21 +2541,13 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(2);
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [{ text: "delivery failed" }],
|
||||
});
|
||||
expect(deliverDiscordReply.mock.calls[1]?.[0]).toMatchObject({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps mutating tool warning finals after successful-looking replies", async () => {
|
||||
it("suppresses mutating tool warning finals after successful-looking replies", async () => {
|
||||
const draftStream = createMockDraftStreamForTest();
|
||||
dispatchInboundMessage.mockImplementationOnce(async (params?: DispatchInboundParams) => {
|
||||
await params?.dispatcher.sendFinalReply({ text: "Done." });
|
||||
@@ -2582,15 +2566,7 @@ describe("processDiscordMessage draft streaming", () => {
|
||||
|
||||
expectPreviewEditContent("Done.");
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
expect(deliverDiscordReply).toHaveBeenCalledTimes(1);
|
||||
expect(firstMockArg(deliverDiscordReply, "deliverDiscordReply")).toMatchObject({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `write file (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
expect(deliverDiscordReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("suppresses reasoning payload delivery to Discord", async () => {
|
||||
|
||||
@@ -66,6 +66,7 @@ import { createDiscordDraftPreviewController } from "./message-handler.draft-pre
|
||||
import type { DiscordMessagePreflightContext } from "./message-handler.preflight.js";
|
||||
import { resolveForwardedMediaList, resolveMediaList } from "./message-utils.js";
|
||||
import { deliverDiscordReply } from "./reply-delivery.js";
|
||||
import { sanitizeDiscordFrontChannelReplyPayloads } from "./reply-safety.js";
|
||||
import { createDiscordReplyTypingFeedback } from "./reply-typing-feedback.js";
|
||||
import {
|
||||
DISCORD_ATTACHMENT_IDLE_TIMEOUT_MS,
|
||||
@@ -111,7 +112,10 @@ function isFallbackOnlyToolWarningFinal(payload: ReplyPayload): boolean {
|
||||
return !resolveSendableOutboundReplyParts(payload).hasMedia;
|
||||
}
|
||||
|
||||
type DiscordReplySkipReason = "aborted before delivery" | "reasoning payload";
|
||||
type DiscordReplySkipReason =
|
||||
| "aborted before delivery"
|
||||
| "reasoning payload"
|
||||
| "internal-only payload";
|
||||
|
||||
export function formatDiscordReplySkip(params: {
|
||||
kind: "tool" | "block" | "final";
|
||||
@@ -621,7 +625,7 @@ async function processDiscordMessageInner(
|
||||
) => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
// Surface so operators don't chase missing replies when an abort
|
||||
// drops a model-produced text payload (see PR for the incident).
|
||||
// drops a model-produced text payload.
|
||||
logVerbose(
|
||||
formatDiscordReplySkip({
|
||||
kind: info.kind,
|
||||
@@ -669,10 +673,24 @@ async function processDiscordMessageInner(
|
||||
})
|
||||
: payload.text;
|
||||
const effectivePayload = finalText !== payload.text ? { ...payload, text: finalText } : payload;
|
||||
const [deliverablePayload] = sanitizeDiscordFrontChannelReplyPayloads([effectivePayload], {
|
||||
kind: info.kind,
|
||||
});
|
||||
if (!deliverablePayload) {
|
||||
logVerbose(
|
||||
formatDiscordReplySkip({
|
||||
kind: info.kind,
|
||||
reason: "internal-only payload",
|
||||
target: deliverTarget,
|
||||
sessionKey: ctxPayload.SessionKey,
|
||||
}),
|
||||
);
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
const draftStream = draftPreview.draftStream;
|
||||
if (draftStream && draftPreview.isProgressMode && info.kind === "block") {
|
||||
const reply = resolveSendableOutboundReplyParts(effectivePayload);
|
||||
if (!reply.hasMedia && !payload.isError) {
|
||||
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
|
||||
if (!reply.hasMedia && !deliverablePayload.isError) {
|
||||
return { visibleReplySent: false };
|
||||
}
|
||||
}
|
||||
@@ -680,22 +698,22 @@ async function processDiscordMessageInner(
|
||||
draftStream &&
|
||||
isFinal &&
|
||||
(!draftPreview.isProgressMode || draftPreview.hasProgressDraftStarted) &&
|
||||
!payload.isError;
|
||||
!deliverablePayload.isError;
|
||||
if (shouldFinalizeDraftPreview) {
|
||||
const reply = resolveSendableOutboundReplyParts(effectivePayload);
|
||||
const reply = resolveSendableOutboundReplyParts(deliverablePayload);
|
||||
const hasMedia = reply.hasMedia;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(effectivePayload);
|
||||
const previewSourceText = finalText ?? ttsSupplement?.spokenText;
|
||||
const ttsSupplement = getReplyPayloadTtsSupplement(deliverablePayload);
|
||||
const previewSourceText = deliverablePayload.text ?? ttsSupplement?.spokenText;
|
||||
const previewFinalText = draftPreview.resolvePreviewFinalText(previewSourceText);
|
||||
const previewReplyToId = replyReference.peek();
|
||||
const hasExplicitReplyDirective =
|
||||
Boolean(effectivePayload.replyToTag || effectivePayload.replyToCurrent) ||
|
||||
Boolean(deliverablePayload.replyToTag || deliverablePayload.replyToCurrent) ||
|
||||
(typeof previewSourceText === "string" &&
|
||||
/\[\[\s*reply_to(?:_current|\s*:)/i.test(previewSourceText));
|
||||
|
||||
const result = await deliverWithFinalizableLivePreviewAdapter({
|
||||
kind: info.kind,
|
||||
payload: effectivePayload,
|
||||
payload: deliverablePayload,
|
||||
adapter: defineFinalizableLivePreviewAdapter({
|
||||
draft: {
|
||||
flush: () => draftPreview.flush(),
|
||||
@@ -710,7 +728,7 @@ async function processDiscordMessageInner(
|
||||
(hasMedia && !ttsSupplement) ||
|
||||
typeof previewFinalText !== "string" ||
|
||||
hasExplicitReplyDirective ||
|
||||
payload.isError
|
||||
deliverablePayload.isError
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
@@ -747,7 +765,7 @@ async function processDiscordMessageInner(
|
||||
replyReference.markSent();
|
||||
},
|
||||
buildSupplementalPayload: () =>
|
||||
ttsSupplement ? buildTtsSupplementMediaPayload(effectivePayload) : undefined,
|
||||
ttsSupplement ? buildTtsSupplementMediaPayload(deliverablePayload) : undefined,
|
||||
deliverSupplemental: async (supplementalPayload) => {
|
||||
if (isProcessAborted(abortSignal)) {
|
||||
return false;
|
||||
@@ -794,9 +812,9 @@ async function processDiscordMessageInner(
|
||||
const fallbackPayload =
|
||||
ttsSupplement &&
|
||||
ttsSupplement.visibleTextAlreadyDelivered !== true &&
|
||||
!effectivePayload.text?.trim()
|
||||
? { ...effectivePayload, text: ttsSupplement.spokenText }
|
||||
: effectivePayload;
|
||||
!deliverablePayload.text?.trim()
|
||||
? { ...deliverablePayload, text: ttsSupplement.spokenText }
|
||||
: deliverablePayload;
|
||||
const replyToId = replyReference.use();
|
||||
notifyFinalReplyStart();
|
||||
await deliverDiscordReply({
|
||||
@@ -849,7 +867,7 @@ async function processDiscordMessageInner(
|
||||
}
|
||||
await deliverDiscordReply({
|
||||
cfg,
|
||||
replies: [effectivePayload],
|
||||
replies: [deliverablePayload],
|
||||
target: deliverTarget,
|
||||
token,
|
||||
accountId,
|
||||
@@ -867,7 +885,7 @@ async function processDiscordMessageInner(
|
||||
kind: info.kind,
|
||||
});
|
||||
replyReference.markSent();
|
||||
if (isFinal && payload.isError !== true) {
|
||||
if (isFinal && deliverablePayload.isError !== true) {
|
||||
markUserFacingFinalDelivered();
|
||||
}
|
||||
return { visibleReplySent: true };
|
||||
|
||||
@@ -176,6 +176,33 @@ describe("deliverDiscordReply", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("strips assistant scaffolding from explicit tool progress payloads", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
{
|
||||
text: [
|
||||
"<think>private reasoning</think>",
|
||||
'<tool_call>{"name":"x"}</tool_call>',
|
||||
"🛠️ run git status",
|
||||
].join("\n"),
|
||||
},
|
||||
],
|
||||
target: "channel:101",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
cfg,
|
||||
textLimit: 2000,
|
||||
kind: "tool",
|
||||
});
|
||||
|
||||
expect(sendDurableMessageBatchMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
payloads: [{ text: "🛠️ run git status" }],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("strips internal execution trace lines at the final Discord send boundary", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
@@ -183,6 +210,7 @@ describe("deliverDiscordReply", () => {
|
||||
text: [
|
||||
"📊 Session Status: current",
|
||||
"🛠️ run git status",
|
||||
"⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
"🛠️ `gh pr view`",
|
||||
"🛠️ `docker compose up`",
|
||||
"🛠️ elevated · `cd /tmp && pnpm test`",
|
||||
@@ -204,6 +232,26 @@ describe("deliverDiscordReply", () => {
|
||||
expect(firstDeliverParams().payloads).toEqual([{ text: "Visible reply." }]);
|
||||
});
|
||||
|
||||
it("drops pure internal tool failure warnings at the final Discord send boundary", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
{
|
||||
text: "⚠️ 🛠️ `run openclaw definitely-not-a-real-subcommand (agent)` failed",
|
||||
isError: true,
|
||||
},
|
||||
],
|
||||
target: "channel:101",
|
||||
token: "token",
|
||||
accountId: "default",
|
||||
runtime,
|
||||
cfg,
|
||||
textLimit: 2000,
|
||||
kind: "final",
|
||||
});
|
||||
|
||||
expect(sendDurableMessageBatchMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("strips serialized tool call blocks at the final Discord send boundary", async () => {
|
||||
await deliverDiscordReply({
|
||||
replies: [
|
||||
|
||||
@@ -1,14 +1,13 @@
|
||||
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
|
||||
import { resolveSendableOutboundReplyParts } from "openclaw/plugin-sdk/reply-payload";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import {
|
||||
sanitizeAssistantVisibleText,
|
||||
sanitizeAssistantVisibleTextWithProfile,
|
||||
} from "openclaw/plugin-sdk/text-chunking";
|
||||
import { stripPlainTextToolCallBlocks } from "openclaw/plugin-sdk/tool-payload";
|
||||
|
||||
const DISCORD_INTERNAL_TRACE_LINE_RE =
|
||||
/^(?:>\s*)?(?:📊|🛠️|📖|📝|🔍|🔎|⚙️)\s*(?:Session Status|Exec|Read|Edit|Write|Patch|Search|Open|Click|Find|Screenshot|Update Plan|Tool Call|Tool Result|Function Call|Shell|Command)\s*:/i;
|
||||
const DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE =
|
||||
/^(?:>\s*)?🛠️\s*(?:(?:(?:elevated|pty)\b\s*(?:·|,)\s*)+)?(?:`{1,2}\s*\S|(?:run|check|fetch|pull|push|view|show|list|switch|create|merge|rebase|stage|restore|reset|stash|search|find|print|copy|move|remove|install|start|cd|git|pnpm|npm|yarn|bun|node|python|python3|bash|sh)\b)/i;
|
||||
const DISCORD_INTERNAL_CHANNEL_LINE_RE =
|
||||
/^(?:>\s*)?(?:analysis|commentary|tool[-_ ]?call|tool[-_ ]?result|function[-_ ]?call|thinking|reasoning)\s*[:=]/i;
|
||||
/^(?:>\s*)?(?:analysis|commentary|thinking|reasoning)\s*[:=]/i;
|
||||
|
||||
function hasNonEmptyRecord(value: unknown): value is Record<string, unknown> {
|
||||
return Boolean(
|
||||
@@ -36,7 +35,11 @@ function hasNonTextReplyPayloadContent(payload: ReplyPayload): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function stripDiscordInternalTraceLines(text: string): string {
|
||||
function collapseExcessBlankLines(text: string): string {
|
||||
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
function stripDiscordInternalChannelLines(text: string): string {
|
||||
let inFence = false;
|
||||
const kept: string[] = [];
|
||||
for (const line of text.split(/\r?\n/)) {
|
||||
@@ -45,31 +48,20 @@ function stripDiscordInternalTraceLines(text: string): string {
|
||||
kept.push(line);
|
||||
continue;
|
||||
}
|
||||
if (!inFence) {
|
||||
const trimmed = line.trim();
|
||||
if (
|
||||
DISCORD_INTERNAL_TRACE_LINE_RE.test(trimmed) ||
|
||||
DISCORD_INTERNAL_COMPACT_COMMAND_TRACE_LINE_RE.test(trimmed) ||
|
||||
DISCORD_INTERNAL_CHANNEL_LINE_RE.test(trimmed)
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
if (!inFence && DISCORD_INTERNAL_CHANNEL_LINE_RE.test(line.trim())) {
|
||||
continue;
|
||||
}
|
||||
kept.push(line);
|
||||
}
|
||||
return kept.join("\n");
|
||||
}
|
||||
|
||||
function collapseExcessBlankLines(text: string): string {
|
||||
return text.replace(/[ \t]+\n/g, "\n").replace(/\n{3,}/g, "\n\n");
|
||||
}
|
||||
|
||||
export function sanitizeDiscordFrontChannelText(text: string): string {
|
||||
const withoutToolCallBlocks = stripPlainTextToolCallBlocks(text);
|
||||
const withoutAssistantScaffolding = sanitizeAssistantVisibleText(withoutToolCallBlocks);
|
||||
const withoutResidualToolCallBlocks = stripPlainTextToolCallBlocks(withoutAssistantScaffolding);
|
||||
const withoutTraceLines = stripDiscordInternalTraceLines(withoutResidualToolCallBlocks);
|
||||
return collapseExcessBlankLines(withoutTraceLines).trim();
|
||||
const withoutChannelLines = stripDiscordInternalChannelLines(withoutResidualToolCallBlocks);
|
||||
return collapseExcessBlankLines(withoutChannelLines).trim();
|
||||
}
|
||||
|
||||
export function sanitizeDiscordFrontChannelReplyPayloads(
|
||||
@@ -82,7 +74,9 @@ export function sanitizeDiscordFrontChannelReplyPayloads(
|
||||
const safeText =
|
||||
typeof payload.text === "string"
|
||||
? preserveVerboseToolProgress
|
||||
? collapseExcessBlankLines(sanitizeAssistantVisibleText(payload.text)).trim()
|
||||
? collapseExcessBlankLines(
|
||||
sanitizeAssistantVisibleTextWithProfile(payload.text, "tool-progress"),
|
||||
).trim()
|
||||
: sanitizeDiscordFrontChannelText(payload.text)
|
||||
: payload.text;
|
||||
const nextPayload =
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OpusError, OpusErrorCode } from "libopus-wasm";
|
||||
import { OpusError } from "libopus-wasm";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
analyzeVoiceReceiveError,
|
||||
@@ -7,6 +7,8 @@ import {
|
||||
noteVoiceDecryptFailure,
|
||||
} from "./receive-recovery.js";
|
||||
|
||||
const OPUS_INVALID_PACKET_CODE = -4;
|
||||
|
||||
describe("voice receive recovery", () => {
|
||||
it("treats passthrough-disabled decrypt errors as decrypt failures", () => {
|
||||
expect(
|
||||
@@ -34,9 +36,7 @@ describe("voice receive recovery", () => {
|
||||
|
||||
it("treats corrupt Opus packets as non-recoverable decode noise", () => {
|
||||
expect(
|
||||
analyzeVoiceReceiveError(
|
||||
new OpusError(OpusErrorCode.InvalidPacket, "not inspected", "decode"),
|
||||
),
|
||||
analyzeVoiceReceiveError(new OpusError(OPUS_INVALID_PACKET_CODE, "not inspected", "decode")),
|
||||
).toEqual({
|
||||
message: "not inspected",
|
||||
isAbortLike: false,
|
||||
@@ -50,7 +50,7 @@ describe("voice receive recovery", () => {
|
||||
const analysis = analyzeVoiceReceiveError({
|
||||
name: "OpusError",
|
||||
message: "libopus decode failed (-4): corrupted stream",
|
||||
code: OpusErrorCode.InvalidPacket,
|
||||
code: OPUS_INVALID_PACKET_CODE,
|
||||
codeName: "InvalidPacket",
|
||||
operation: "decode",
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { OpusErrorCode, isOpusError } from "libopus-wasm";
|
||||
import { OpusError } from "libopus-wasm";
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const DECRYPT_FAILURE_WINDOW_MS = 30_000;
|
||||
@@ -6,6 +6,7 @@ const DECRYPT_FAILURE_RECONNECT_THRESHOLD = 3;
|
||||
const DECRYPT_FAILURE_MARKER = "DecryptionFailed(";
|
||||
const DAVE_PASSTHROUGH_DISABLED_MARKER = "UnencryptedWhenPassthroughDisabled";
|
||||
const WASM_MEMORY_ACCESS_MARKER = "memory access out of bounds";
|
||||
const OPUS_INVALID_PACKET_CODE = -4;
|
||||
|
||||
export const DAVE_RECEIVE_PASSTHROUGH_INITIAL_EXPIRY_SECONDS = 30;
|
||||
export const DAVE_RECEIVE_PASSTHROUGH_REARM_EXPIRY_SECONDS = 15;
|
||||
@@ -83,10 +84,24 @@ function isAbortLikeReceiveError(err: unknown): boolean {
|
||||
}
|
||||
|
||||
function isOpusDecodeInvalidPacketError(err: unknown): boolean {
|
||||
if (!err || typeof err !== "object") {
|
||||
return false;
|
||||
}
|
||||
const maybeOpusError = err as {
|
||||
name?: unknown;
|
||||
code?: unknown;
|
||||
codeName?: unknown;
|
||||
operation?: unknown;
|
||||
};
|
||||
const isDecodeOperation =
|
||||
maybeOpusError.operation === "decode" || maybeOpusError.operation === "decodeFloat";
|
||||
const isInvalidPacket =
|
||||
maybeOpusError.code === OPUS_INVALID_PACKET_CODE ||
|
||||
maybeOpusError.codeName === "InvalidPacket";
|
||||
return (
|
||||
isOpusError(err) &&
|
||||
err.code === OpusErrorCode.InvalidPacket &&
|
||||
(err.operation === "decode" || err.operation === "decodeFloat")
|
||||
isDecodeOperation &&
|
||||
isInvalidPacket &&
|
||||
(err instanceof OpusError || maybeOpusError.name === "OpusError")
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
3
extensions/feishu/runtime-setter-api.ts
Normal file
3
extensions/feishu/runtime-setter-api.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
// Narrow entry point for setFeishuRuntime. Keep setup/runtime registration
|
||||
// from pulling in the broader Feishu runtime-api barrel.
|
||||
export { setFeishuRuntime } from "./src/runtime.js";
|
||||
@@ -17,5 +17,16 @@ describe("feishu setup entry", () => {
|
||||
expect(setupEntry.features).toEqual({ legacyStateMigrations: true });
|
||||
expect(typeof setupEntry.loadSetupPlugin).toBe("function");
|
||||
expect(setupEntry.loadLegacyStateMigrationDetector?.()).toBeTypeOf("function");
|
||||
expect(typeof setupEntry.setChannelRuntime).toBe("function");
|
||||
});
|
||||
|
||||
it("wires the Feishu runtime from setup-only registration", async () => {
|
||||
const { default: setupEntry } = await import("./setup-entry.js");
|
||||
const runtime = { channel: { inbound: { run: vi.fn() } } };
|
||||
|
||||
setupEntry.setChannelRuntime?.(runtime as never);
|
||||
|
||||
const { getFeishuRuntime } = await import("./src/runtime.js");
|
||||
expect(getFeishuRuntime()).toBe(runtime);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -17,4 +17,8 @@ export default defineBundledChannelSetupEntry({
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-setter-api.js",
|
||||
exportName: "setFeishuRuntime",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -1628,6 +1628,76 @@ describe("google transport stream", () => {
|
||||
expect(generationConfig).not.toHaveProperty("thinkingConfig");
|
||||
});
|
||||
|
||||
it("forwards configured stop sequences to the Gemini generationConfig", () => {
|
||||
const params = buildGoogleGenerativeAiParams(
|
||||
buildGeminiModel(),
|
||||
{
|
||||
messages: [{ role: "user", content: "hello", timestamp: 0 }],
|
||||
} as never,
|
||||
{
|
||||
stop: ["</tool>", "\n\nObservation:"],
|
||||
} as never,
|
||||
);
|
||||
|
||||
const generationConfig = requireGenerationConfig(params);
|
||||
expect(generationConfig.stopSequences).toEqual(["</tool>", "\n\nObservation:"]);
|
||||
});
|
||||
|
||||
it("omits stopSequences when the stop list is empty", () => {
|
||||
const params = buildGoogleGenerativeAiParams(
|
||||
buildGeminiModel(),
|
||||
{
|
||||
messages: [{ role: "user", content: "hello", timestamp: 0 }],
|
||||
} as never,
|
||||
{
|
||||
stop: [],
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(params.generationConfig ?? {}).not.toHaveProperty("stopSequences");
|
||||
});
|
||||
|
||||
it("sends stopSequences in the serialized Gemini request body via the guarded fetch transport", async () => {
|
||||
guardedFetchMock.mockResolvedValueOnce(buildSseResponse([]));
|
||||
|
||||
const model = attachModelProviderRequestTransport(
|
||||
{
|
||||
id: "gemini-3.1-pro-preview",
|
||||
name: "Gemini 3.1 Pro Preview",
|
||||
api: "google-generative-ai",
|
||||
provider: "google",
|
||||
baseUrl: "https://generativelanguage.googleapis.com",
|
||||
reasoning: true,
|
||||
input: ["text"],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 128000,
|
||||
maxTokens: 8192,
|
||||
} satisfies Model<"google-generative-ai">,
|
||||
{},
|
||||
);
|
||||
|
||||
const streamFn = createGoogleGenerativeAiTransportStreamFn();
|
||||
const stream = await Promise.resolve(
|
||||
streamFn(
|
||||
model,
|
||||
{
|
||||
messages: [{ role: "user", content: "hello", timestamp: 0 }],
|
||||
} as Parameters<typeof streamFn>[1],
|
||||
{
|
||||
apiKey: "gemini-api-key",
|
||||
stop: ["</tool>", "\n\nObservation:"],
|
||||
} as Parameters<typeof streamFn>[2],
|
||||
),
|
||||
);
|
||||
await stream.result();
|
||||
|
||||
const guardedCall = requireMockCall(guardedFetchMock, 0, "guarded fetch");
|
||||
const init = requireRequestInit(guardedCall, "guarded fetch");
|
||||
const payload = parseRequestJsonBody(init);
|
||||
const generationConfig = requireGenerationConfig(payload);
|
||||
expect(generationConfig.stopSequences).toEqual(["</tool>", "\n\nObservation:"]);
|
||||
});
|
||||
|
||||
it("strips explicit thinkingBudget=0 but preserves includeThoughts for Gemini 2.5 Pro", () => {
|
||||
const params = buildGoogleGenerativeAiParams(
|
||||
buildGeminiModel(),
|
||||
|
||||
@@ -703,6 +703,9 @@ export function buildGoogleGenerativeAiParams(
|
||||
if (typeof options?.maxTokens === "number") {
|
||||
generationConfig.maxOutputTokens = options.maxTokens;
|
||||
}
|
||||
if (options?.stop !== undefined && options.stop.length > 0) {
|
||||
generationConfig.stopSequences = options.stop;
|
||||
}
|
||||
const thinkingConfig = resolveGoogleThinkingConfig(model, options);
|
||||
if (thinkingConfig) {
|
||||
generationConfig.thinkingConfig = thinkingConfig;
|
||||
|
||||
@@ -281,6 +281,82 @@ describe("kimi tool-call markup wrapper", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("strips Anthropic cache_control markers before Kimi requests are sent", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream({
|
||||
system: [{ type: "text", text: "stable", cache_control: { type: "ephemeral", ttl: "1h" } }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello", cache_control: { type: "ephemeral" } },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [
|
||||
{
|
||||
type: "text",
|
||||
text: "done",
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
],
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_2",
|
||||
name: "persist",
|
||||
input: {
|
||||
cache_control: "tool argument",
|
||||
nested: { cache_control: "nested argument" },
|
||||
},
|
||||
cache_control: { type: "ephemeral" },
|
||||
},
|
||||
{ type: "text", text: "bye" },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const wrapped = createKimiThinkingWrapper(baseStreamFn, "enabled");
|
||||
void wrapped(
|
||||
{
|
||||
api: "anthropic-messages",
|
||||
provider: "kimi",
|
||||
id: "kimi-code",
|
||||
} as Model<"anthropic-messages">,
|
||||
{ messages: [] } as Context,
|
||||
{},
|
||||
);
|
||||
|
||||
expect(getCapturedPayload()).toEqual({
|
||||
system: [{ type: "text", text: "stable" }],
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: [
|
||||
{ type: "text", text: "hello" },
|
||||
{
|
||||
type: "tool_result",
|
||||
tool_use_id: "tool_1",
|
||||
content: [{ type: "text", text: "done" }],
|
||||
},
|
||||
{
|
||||
type: "tool_use",
|
||||
id: "tool_2",
|
||||
name: "persist",
|
||||
input: {
|
||||
cache_control: "tool argument",
|
||||
nested: { cache_control: "nested argument" },
|
||||
},
|
||||
},
|
||||
{ type: "text", text: "bye" },
|
||||
],
|
||||
},
|
||||
],
|
||||
thinking: { type: "enabled" },
|
||||
});
|
||||
});
|
||||
|
||||
it("lets explicit model params keep Kimi thinking disabled even when session thinking is on", () => {
|
||||
const { streamFn: baseStreamFn, getCapturedPayload } = createPayloadCapturingStream();
|
||||
|
||||
|
||||
@@ -394,9 +394,51 @@ export function createKimiThinkingWrapper(
|
||||
delete payloadObj.reasoning;
|
||||
delete payloadObj.reasoning_effort;
|
||||
delete payloadObj.reasoningEffort;
|
||||
stripAnthropicCacheControlMarkers(payloadObj);
|
||||
});
|
||||
}
|
||||
|
||||
function stripContentBlockCacheControl(block: unknown): void {
|
||||
if (!block || typeof block !== "object") {
|
||||
return;
|
||||
}
|
||||
|
||||
const record = block as Record<string, unknown>;
|
||||
delete record.cache_control;
|
||||
|
||||
if (record.type === "tool_result" && Array.isArray(record.content)) {
|
||||
for (const nestedBlock of record.content) {
|
||||
stripContentBlockCacheControl(nestedBlock);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function stripContentArrayCacheControl(value: unknown): void {
|
||||
if (!Array.isArray(value)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const block of value) {
|
||||
stripContentBlockCacheControl(block);
|
||||
}
|
||||
}
|
||||
|
||||
function stripAnthropicCacheControlMarkers(payloadObj: Record<string, unknown>): void {
|
||||
stripContentArrayCacheControl(payloadObj.system);
|
||||
|
||||
if (!Array.isArray(payloadObj.messages)) {
|
||||
return;
|
||||
}
|
||||
|
||||
for (const message of payloadObj.messages) {
|
||||
if (!message || typeof message !== "object") {
|
||||
continue;
|
||||
}
|
||||
|
||||
stripContentArrayCacheControl((message as Record<string, unknown>).content);
|
||||
}
|
||||
}
|
||||
|
||||
export function wrapKimiProviderStream(ctx: ProviderWrapStreamFnContext): StreamFn {
|
||||
const thinkingConfig = resolveKimiThinkingConfig({
|
||||
configuredThinking: ctx.extraParams?.thinking,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
resolveEmbeddingTimeoutMs,
|
||||
resolveMemoryIndexConcurrency,
|
||||
@@ -97,52 +97,61 @@ describe("local embedding worker failure detection", () => {
|
||||
|
||||
describe("memory embedding timeout abort", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("aborts the provider operation when the timeout fires", async () => {
|
||||
vi.useFakeTimers();
|
||||
let signalSeen: AbortSignal | undefined;
|
||||
|
||||
const result = expect(
|
||||
runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings query timed out after 0s",
|
||||
run: async (signal) => {
|
||||
signalSeen = signal;
|
||||
return await new Promise<number[]>((resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("memory embeddings query timed out after 0s");
|
||||
const resultPromise = runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings query timed out after 0s",
|
||||
run: async (signal) => {
|
||||
signalSeen = signal;
|
||||
return await new Promise<number[]>((resolve, reject) => {
|
||||
signal.addEventListener(
|
||||
"abort",
|
||||
() => reject(toLintErrorObject(signal.reason, "Non-Error rejection")),
|
||||
{ once: true },
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const rejection = expect(resultPromise).rejects.toThrow(
|
||||
"memory embeddings query timed out after 0s",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await result;
|
||||
await rejection;
|
||||
|
||||
expect(signalSeen?.aborted).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the timeout error when a provider abort listener rejects generically", async () => {
|
||||
vi.useFakeTimers();
|
||||
const result = expect(
|
||||
runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings batch timed out after 0s",
|
||||
run: async (signal) =>
|
||||
await new Promise<number[]>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
}),
|
||||
).rejects.toThrow("memory embeddings batch timed out after 0s");
|
||||
const resultPromise = runEmbeddingOperationWithTimeout({
|
||||
timeoutMs: 1,
|
||||
message: "memory embeddings batch timed out after 0s",
|
||||
run: async (signal) =>
|
||||
await new Promise<number[]>((_resolve, reject) => {
|
||||
signal.addEventListener("abort", () => reject(new Error("provider aborted")), {
|
||||
once: true,
|
||||
});
|
||||
}),
|
||||
});
|
||||
|
||||
const rejection = expect(resultPromise).rejects.toThrow(
|
||||
"memory embeddings batch timed out after 0s",
|
||||
);
|
||||
await vi.advanceTimersByTimeAsync(1);
|
||||
await result;
|
||||
await rejection;
|
||||
});
|
||||
|
||||
it("caps operation watchdog timers before scheduling", async () => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
|
||||
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { compileMemoryWikiVault } from "./compile.js";
|
||||
import { renderWikiMarkdown } from "./markdown.js";
|
||||
import { createMemoryWikiTestHarness } from "./test-helpers.js";
|
||||
@@ -16,6 +16,16 @@ describe("compileMemoryWikiVault", () => {
|
||||
suiteRoot = await fs.mkdtemp(path.join(os.tmpdir(), "memory-wiki-compile-suite-"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (suiteRoot) {
|
||||
await fs.rm(suiteRoot, { recursive: true, force: true });
|
||||
@@ -148,9 +158,7 @@ describe("compileMemoryWikiVault", () => {
|
||||
activePageReads += 1;
|
||||
maxActivePageReads = Math.max(maxActivePageReads, activePageReads);
|
||||
try {
|
||||
await new Promise((resolve) => {
|
||||
setTimeout(resolve, 5);
|
||||
});
|
||||
await Promise.resolve();
|
||||
return await originalReadFile(...args);
|
||||
} finally {
|
||||
activePageReads -= 1;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const { fetchWithSsrFGuardMock } = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuardMock: vi.fn(),
|
||||
@@ -88,8 +88,13 @@ describe("buildAssistantMessage", () => {
|
||||
});
|
||||
|
||||
describe("createOllamaStreamFn thinking events", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuardMock.mockReset();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
function makeNdjsonBody(chunks: Array<Record<string, unknown>>): ReadableStream<Uint8Array> {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { expectExplicitMusicGenerationCapabilities } from "openclaw/plugin-sdk/provider-test-contracts";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildOpenRouterMusicGenerationProvider } from "./music-generation-provider.js";
|
||||
|
||||
const {
|
||||
@@ -74,12 +74,33 @@ function postRequest(): Record<string, unknown> {
|
||||
return request as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function resetOpenRouterMusicMocks() {
|
||||
assertOkOrThrowHttpErrorMock.mockResolvedValue(undefined);
|
||||
postJsonRequestMock.mockReset();
|
||||
resolveApiKeyForProviderMock.mockResolvedValue({
|
||||
apiKey: "openrouter-key",
|
||||
source: "env",
|
||||
mode: "api-key",
|
||||
});
|
||||
resolveProviderHttpRequestConfigMock.mockImplementation((params: Record<string, unknown>) => ({
|
||||
baseUrl: params.baseUrl ?? params.defaultBaseUrl,
|
||||
allowPrivateNetwork: false,
|
||||
headers: new Headers(params.defaultHeaders as HeadersInit | undefined),
|
||||
dispatcherPolicy: undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
describe("openrouter music generation provider", () => {
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
resetOpenRouterMusicMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
assertOkOrThrowHttpErrorMock.mockClear();
|
||||
postJsonRequestMock.mockReset();
|
||||
resolveApiKeyForProviderMock.mockClear();
|
||||
resolveProviderHttpRequestConfigMock.mockClear();
|
||||
resetOpenRouterMusicMocks();
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("declares explicit mode capabilities", () => {
|
||||
|
||||
@@ -243,6 +243,11 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
strictness: "requires-true",
|
||||
selectors: ["channelIds"],
|
||||
},
|
||||
{
|
||||
path: "dataHandling.memory.denySessionTranscriptIndexing",
|
||||
strictness: "requires-true",
|
||||
selectors: ["agentIds"],
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -549,6 +554,10 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
"policy/sandbox-container-runtime-socket-mount",
|
||||
"policy/sandbox-container-unconfined-profile",
|
||||
"policy/sandbox-browser-cdp-source-range-missing",
|
||||
"policy/data-handling-redaction-disabled",
|
||||
"policy/data-handling-telemetry-content-capture",
|
||||
"policy/data-handling-session-retention-not-enforced",
|
||||
"policy/data-handling-session-transcript-memory-enabled",
|
||||
"policy/secrets-unmanaged-provider",
|
||||
"policy/secrets-denied-provider-source",
|
||||
"policy/secrets-insecure-provider",
|
||||
@@ -652,7 +661,6 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
["tools settings array", { tools: { settings: [] } }, "oc://policy.jsonc/tools/settings"],
|
||||
["tools entries object", { tools: { entries: {} } }, "oc://policy.jsonc/tools/entries"],
|
||||
["tools profiles array", { tools: { profiles: [] } }, "oc://policy.jsonc/tools/profiles"],
|
||||
|
||||
[
|
||||
"tools profiles allow string",
|
||||
{ tools: { profiles: { allow: "coding" } } },
|
||||
@@ -985,6 +993,182 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects unsupported policy keys across policy namespaces", async () => {
|
||||
const cases: readonly {
|
||||
readonly label: string;
|
||||
readonly policy: unknown;
|
||||
readonly target: string;
|
||||
}[] = [
|
||||
{ label: "top-level", policy: { channel: {} }, target: "oc://policy.jsonc/channel" },
|
||||
{
|
||||
label: "tools top-level",
|
||||
policy: { tools: { execPolicy: { allowHosts: ["sandbox"] } } },
|
||||
target: "oc://policy.jsonc/tools/execPolicy",
|
||||
},
|
||||
{
|
||||
label: "tools settings",
|
||||
policy: { tools: { settings: {} } },
|
||||
target: "oc://policy.jsonc/tools/settings",
|
||||
},
|
||||
{
|
||||
label: "tools entries",
|
||||
policy: { tools: { entries: [] } },
|
||||
target: "oc://policy.jsonc/tools/entries",
|
||||
},
|
||||
{
|
||||
label: "tools profile",
|
||||
policy: { tools: { profiles: { deny: ["full"] } } },
|
||||
target: "oc://policy.jsonc/tools/profiles/deny",
|
||||
},
|
||||
{
|
||||
label: "tools exec",
|
||||
policy: { tools: { exec: { allowShells: ["bash"] } } },
|
||||
target: "oc://policy.jsonc/tools/exec/allowShells",
|
||||
},
|
||||
{
|
||||
label: "tools fs",
|
||||
policy: { tools: { fs: { allowOutsideWorkspace: true } } },
|
||||
target: "oc://policy.jsonc/tools/fs/allowOutsideWorkspace",
|
||||
},
|
||||
{
|
||||
label: "tools alsoAllow",
|
||||
policy: { tools: { alsoAllow: { denied: ["exec"] } } },
|
||||
target: "oc://policy.jsonc/tools/alsoAllow/denied",
|
||||
},
|
||||
{
|
||||
label: "channels",
|
||||
policy: { channels: { allowRules: [] } },
|
||||
target: "oc://policy.jsonc/channels/allowRules",
|
||||
},
|
||||
{
|
||||
label: "channel deny rule",
|
||||
policy: { channels: { denyRules: [{ when: { provider: "telegram" }, action: "deny" }] } },
|
||||
target: "oc://policy.jsonc/channels/denyRules/#0/action",
|
||||
},
|
||||
{
|
||||
label: "channel deny selector",
|
||||
policy: {
|
||||
channels: { denyRules: [{ when: { provider: "telegram", channel: "stable" } }] },
|
||||
},
|
||||
target: "oc://policy.jsonc/channels/denyRules/#0/when/channel",
|
||||
},
|
||||
{
|
||||
label: "ingress top-level",
|
||||
policy: { ingress: { directMessages: {} } },
|
||||
target: "oc://policy.jsonc/ingress/directMessages",
|
||||
},
|
||||
{
|
||||
label: "ingress session",
|
||||
policy: { ingress: { session: { requiredScope: "per-channel-peer" } } },
|
||||
target: "oc://policy.jsonc/ingress/session/requiredScope",
|
||||
},
|
||||
{
|
||||
label: "ingress channels",
|
||||
policy: { ingress: { channels: { allowOpenGroups: false } } },
|
||||
target: "oc://policy.jsonc/ingress/channels/allowOpenGroups",
|
||||
},
|
||||
{ label: "mcp", policy: { mcp: { clients: {} } }, target: "oc://policy.jsonc/mcp/clients" },
|
||||
{
|
||||
label: "mcp servers",
|
||||
policy: { mcp: { servers: { require: ["docs"] } } },
|
||||
target: "oc://policy.jsonc/mcp/servers/require",
|
||||
},
|
||||
{
|
||||
label: "models",
|
||||
policy: { models: { modelRefs: {} } },
|
||||
target: "oc://policy.jsonc/models/modelRefs",
|
||||
},
|
||||
{
|
||||
label: "models providers",
|
||||
policy: { models: { providers: { require: ["openai"] } } },
|
||||
target: "oc://policy.jsonc/models/providers/require",
|
||||
},
|
||||
{
|
||||
label: "network",
|
||||
policy: { network: { publicNetwork: {} } },
|
||||
target: "oc://policy.jsonc/network/publicNetwork",
|
||||
},
|
||||
{
|
||||
label: "network privateNetwork",
|
||||
policy: { network: { privateNetwork: { deny: true } } },
|
||||
target: "oc://policy.jsonc/network/privateNetwork/deny",
|
||||
},
|
||||
{
|
||||
label: "gateway top-level",
|
||||
policy: { gateway: { bind: { allowNonLoopback: false } } },
|
||||
target: "oc://policy.jsonc/gateway/bind",
|
||||
},
|
||||
{
|
||||
label: "gateway exposure",
|
||||
policy: { gateway: { exposure: { allowPublicBind: false } } },
|
||||
target: "oc://policy.jsonc/gateway/exposure/allowPublicBind",
|
||||
},
|
||||
{
|
||||
label: "gateway auth",
|
||||
policy: { gateway: { auth: { allowDisabled: false } } },
|
||||
target: "oc://policy.jsonc/gateway/auth/allowDisabled",
|
||||
},
|
||||
{
|
||||
label: "agents",
|
||||
policy: { agents: { tools: {} } },
|
||||
target: "oc://policy.jsonc/agents/tools",
|
||||
},
|
||||
{
|
||||
label: "agents workspace",
|
||||
policy: { agents: { workspace: { requireReadOnly: true } } },
|
||||
target: "oc://policy.jsonc/agents/workspace/requireReadOnly",
|
||||
},
|
||||
{
|
||||
label: "dataHandling",
|
||||
policy: { dataHandling: { logs: { requireRedaction: true } } },
|
||||
target: "oc://policy.jsonc/dataHandling/logs",
|
||||
},
|
||||
{
|
||||
label: "dataHandling nested",
|
||||
policy: { dataHandling: { telemetry: { allowCaptureContent: false } } },
|
||||
target: "oc://policy.jsonc/dataHandling/telemetry/allowCaptureContent",
|
||||
},
|
||||
{
|
||||
label: "secrets",
|
||||
policy: { secrets: { requireVault: true } },
|
||||
target: "oc://policy.jsonc/secrets/requireVault",
|
||||
},
|
||||
{
|
||||
label: "auth",
|
||||
policy: { auth: { providers: {} } },
|
||||
target: "oc://policy.jsonc/auth/providers",
|
||||
},
|
||||
{
|
||||
label: "auth profiles",
|
||||
policy: { auth: { profiles: { requireProvider: true } } },
|
||||
target: "oc://policy.jsonc/auth/profiles/requireProvider",
|
||||
},
|
||||
];
|
||||
|
||||
for (const testCase of cases) {
|
||||
const configPath = join(workspaceDir, `${testCase.label.replaceAll(" ", "-")}.jsonc`);
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify(testCase.policy),
|
||||
"utf-8",
|
||||
);
|
||||
clearHealthChecksForTest();
|
||||
resetPolicyDoctorChecksForTest();
|
||||
|
||||
const result = await runPolicyChecks(ctx(configPath, cfgWithPolicy()));
|
||||
|
||||
expect(result.findings, testCase.label).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "policy/policy-jsonc-invalid",
|
||||
severity: "error",
|
||||
path: "policy.jsonc",
|
||||
target: testCase.target,
|
||||
}),
|
||||
]);
|
||||
}
|
||||
});
|
||||
|
||||
it("reports a policy hash mismatch when expectedHash is configured", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
@@ -1126,6 +1310,7 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
includeIngress: false,
|
||||
includeGatewayExposure: false,
|
||||
includeAgentWorkspace: false,
|
||||
includeDataHandling: false,
|
||||
includeToolPosture: false,
|
||||
includeSandboxPosture: false,
|
||||
includeSecrets: false,
|
||||
@@ -1159,6 +1344,7 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
includeIngress: false,
|
||||
includeGatewayExposure: false,
|
||||
includeAgentWorkspace: false,
|
||||
includeDataHandling: false,
|
||||
includeToolPosture: false,
|
||||
includeSandboxPosture: false,
|
||||
includeSecrets: false,
|
||||
@@ -1204,6 +1390,7 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
includeIngress: false,
|
||||
includeGatewayExposure: false,
|
||||
includeAgentWorkspace: false,
|
||||
includeDataHandling: false,
|
||||
includeToolPosture: false,
|
||||
includeSandboxPosture: false,
|
||||
includeSecrets: false,
|
||||
@@ -1235,6 +1422,7 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
includeIngress: false,
|
||||
includeGatewayExposure: false,
|
||||
includeAgentWorkspace: false,
|
||||
includeDataHandling: false,
|
||||
includeToolPosture: false,
|
||||
includeSandboxPosture: false,
|
||||
includeSecrets: false,
|
||||
@@ -1243,6 +1431,7 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
expect(evidence).not.toHaveProperty("ingress");
|
||||
expect(evidence).not.toHaveProperty("gatewayExposure");
|
||||
expect(evidence).not.toHaveProperty("agentWorkspace");
|
||||
expect(evidence).not.toHaveProperty("dataHandling");
|
||||
expect(evidence).not.toHaveProperty("sandboxPosture");
|
||||
expect(evidence).not.toHaveProperty("secrets");
|
||||
expect(evidence).not.toHaveProperty("authProfiles");
|
||||
@@ -7217,6 +7406,404 @@ describe("registerPolicyDoctorChecks", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports data-handling conformance findings from config posture", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
const cfg = {
|
||||
...cfgWithPolicy(),
|
||||
logging: { redactSensitive: "off" },
|
||||
diagnostics: { otel: { enabled: true, captureContent: { enabled: true, toolInputs: true } } },
|
||||
session: { maintenance: { mode: "warn" } },
|
||||
memory: { backend: "qmd", qmd: { sessions: { enabled: true } } },
|
||||
} as unknown as OpenClawConfig;
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({
|
||||
dataHandling: {
|
||||
sensitiveLogging: { requireRedaction: true },
|
||||
telemetry: { denyContentCapture: true },
|
||||
retention: { requireSessionMaintenance: true },
|
||||
memory: { denySessionTranscriptIndexing: true },
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfg));
|
||||
const evidence = collectPolicyEvidence(cfg as unknown as Record<string, unknown>);
|
||||
|
||||
expect(evidence.dataHandling).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "sensitiveLoggingRedaction",
|
||||
source: "oc://openclaw.config/logging/redactSensitive",
|
||||
value: false,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "telemetryContentCapture",
|
||||
source: "oc://openclaw.config/diagnostics/otel/captureContent",
|
||||
value: true,
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "sessionRetentionMode",
|
||||
source: "oc://openclaw.config/session/maintenance/mode",
|
||||
value: "warn",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "memorySessionTranscriptIndexing",
|
||||
source: "oc://openclaw.config/memory/qmd/sessions/enabled",
|
||||
value: true,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "policy/data-handling-redaction-disabled",
|
||||
ocPath: "oc://openclaw.config/logging/redactSensitive",
|
||||
requirement: "oc://policy.jsonc/dataHandling/sensitiveLogging/requireRedaction",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
checkId: "policy/data-handling-telemetry-content-capture",
|
||||
ocPath: "oc://openclaw.config/diagnostics/otel/captureContent",
|
||||
requirement: "oc://policy.jsonc/dataHandling/telemetry/denyContentCapture",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
checkId: "policy/data-handling-session-retention-not-enforced",
|
||||
ocPath: "oc://openclaw.config/session/maintenance/mode",
|
||||
requirement: "oc://policy.jsonc/dataHandling/retention/requireSessionMaintenance",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
checkId: "policy/data-handling-session-transcript-memory-enabled",
|
||||
ocPath: "oc://openclaw.config/memory/qmd/sessions/enabled",
|
||||
requirement: "oc://policy.jsonc/dataHandling/memory/denySessionTranscriptIndexing",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("treats omitted session maintenance mode as enforce for retention conformance", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
const cfg = {
|
||||
...cfgWithPolicy(),
|
||||
session: {},
|
||||
} as unknown as OpenClawConfig;
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({
|
||||
dataHandling: {
|
||||
retention: { requireSessionMaintenance: true },
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfg));
|
||||
const evidence = collectPolicyEvidence(cfg as unknown as Record<string, unknown>);
|
||||
|
||||
expect(evidence.dataHandling).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
kind: "sessionRetentionMode",
|
||||
source: "oc://openclaw.config/session/maintenance/mode",
|
||||
value: "enforce",
|
||||
explicit: false,
|
||||
}),
|
||||
]),
|
||||
);
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not treat disabled telemetry capture subkeys as content capture", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
const cfg = {
|
||||
...cfgWithPolicy(),
|
||||
diagnostics: { otel: { captureContent: { toolInputs: true } } },
|
||||
} as unknown as OpenClawConfig;
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({ dataHandling: { telemetry: { denyContentCapture: true } } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfg));
|
||||
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not report inert telemetry capture config", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
const cfg = {
|
||||
...cfgWithPolicy(),
|
||||
diagnostics: {
|
||||
enabled: false,
|
||||
otel: { enabled: true, captureContent: true },
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({ dataHandling: { telemetry: { denyContentCapture: true } } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfg));
|
||||
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports OTEL log body content capture without trace export", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
const cfg = {
|
||||
...cfgWithPolicy(),
|
||||
diagnostics: {
|
||||
otel: { enabled: true, traces: false, logs: true, captureContent: true },
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({ dataHandling: { telemetry: { denyContentCapture: true } } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfg));
|
||||
|
||||
expect(result.findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "policy/data-handling-telemetry-content-capture",
|
||||
ocPath: "oc://openclaw.config/diagnostics/otel/captureContent",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not treat trace-only content capture subkeys as log body capture", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
const cfg = {
|
||||
...cfgWithPolicy(),
|
||||
diagnostics: {
|
||||
otel: {
|
||||
enabled: true,
|
||||
traces: false,
|
||||
logs: true,
|
||||
captureContent: { enabled: true, toolInputs: true },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({ dataHandling: { telemetry: { denyContentCapture: true } } }),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfg));
|
||||
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("supports agent-scoped session transcript memory conformance", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
const cfg = {
|
||||
...cfgWithPolicy(),
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: { experimental: { sessionMemory: true }, sources: ["memory", "sessions"] },
|
||||
},
|
||||
list: [
|
||||
{ id: "sebby" },
|
||||
{ id: "buddy", memorySearch: { experimental: { sessionMemory: false } } },
|
||||
],
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({
|
||||
scopes: {
|
||||
restricted: {
|
||||
agentIds: ["sebby"],
|
||||
dataHandling: { memory: { denySessionTranscriptIndexing: true } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfg));
|
||||
|
||||
expect(result.findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "policy/data-handling-session-transcript-memory-enabled",
|
||||
ocPath: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory",
|
||||
requirement:
|
||||
"oc://policy.jsonc/scopes/restricted/dataHandling/memory/denySessionTranscriptIndexing",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("applies agent-scoped data-handling memory claims to inherited default posture", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
const cfg = {
|
||||
...cfgWithPolicy(),
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: { experimental: { sessionMemory: true }, sources: ["sessions"] },
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({
|
||||
scopes: {
|
||||
restricted: {
|
||||
agentIds: ["release"],
|
||||
dataHandling: { memory: { denySessionTranscriptIndexing: true } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfg));
|
||||
|
||||
expect(result.findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "policy/data-handling-session-transcript-memory-enabled",
|
||||
ocPath: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory",
|
||||
requirement:
|
||||
"oc://policy.jsonc/scopes/restricted/dataHandling/memory/denySessionTranscriptIndexing",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not report inert memory transcript indexing config", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
const cfg = {
|
||||
...cfgWithPolicy(),
|
||||
memory: { qmd: { sessions: { enabled: true } } },
|
||||
agents: {
|
||||
defaults: {
|
||||
memorySearch: {
|
||||
enabled: false,
|
||||
experimental: { sessionMemory: true },
|
||||
sources: ["sessions"],
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as OpenClawConfig;
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({
|
||||
dataHandling: { memory: { denySessionTranscriptIndexing: true } },
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfg));
|
||||
|
||||
expect(result.findings).toEqual([]);
|
||||
});
|
||||
|
||||
it("reports malformed data-handling policy sections", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({
|
||||
dataHandling: {
|
||||
sensitiveLogging: true,
|
||||
memory: [],
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
|
||||
|
||||
expect(result.findings).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
checkId: "policy/policy-jsonc-invalid",
|
||||
target: "oc://policy.jsonc/dataHandling/sensitiveLogging",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
checkId: "policy/policy-jsonc-invalid",
|
||||
target: "oc://policy.jsonc/dataHandling/memory",
|
||||
}),
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects scoped data-handling rules that cannot be agent-scoped", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({
|
||||
scopes: {
|
||||
restricted: {
|
||||
agentIds: ["sebby"],
|
||||
dataHandling: { telemetry: { denyContentCapture: true } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
|
||||
|
||||
expect(result.findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "policy/policy-jsonc-invalid",
|
||||
target: "oc://policy.jsonc/scopes/restricted/dataHandling/telemetry",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects malformed scoped data-handling memory rules", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
await fs.writeFile(
|
||||
join(workspaceDir, "policy.jsonc"),
|
||||
JSON.stringify({
|
||||
scopes: {
|
||||
restricted: {
|
||||
agentIds: ["sebby"],
|
||||
dataHandling: { memory: { denySessionTranscriptIndexing: "true" } },
|
||||
},
|
||||
},
|
||||
}),
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
registerPolicyDoctorChecks();
|
||||
const result = await runDoctorLintChecks(ctx(configPath, cfgWithPolicy()));
|
||||
|
||||
expect(result.findings).toEqual([
|
||||
expect.objectContaining({
|
||||
checkId: "policy/policy-jsonc-invalid",
|
||||
target:
|
||||
"oc://policy.jsonc/scopes/restricted/dataHandling/memory/denySessionTranscriptIndexing",
|
||||
}),
|
||||
]);
|
||||
});
|
||||
|
||||
it("reports malformed secrets policy values before applying secrets checks", async () => {
|
||||
const configPath = join(workspaceDir, "openclaw.jsonc");
|
||||
await fs.writeFile(configPath, "{}", "utf-8");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
|
||||
import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { coerceSecretRef } from "openclaw/plugin-sdk/secret-input";
|
||||
import {
|
||||
asBoolean as readBoolean,
|
||||
@@ -48,6 +49,7 @@ export type PolicyEvidence = {
|
||||
readonly ingress?: readonly PolicyIngressEvidence[];
|
||||
readonly gatewayExposure?: readonly PolicyGatewayExposureEvidence[];
|
||||
readonly agentWorkspace?: readonly PolicyAgentWorkspaceEvidence[];
|
||||
readonly dataHandling?: readonly PolicyDataHandlingEvidence[];
|
||||
readonly secrets?: readonly PolicySecretEvidence[];
|
||||
readonly authProfiles?: readonly PolicyAuthProfileEvidence[];
|
||||
};
|
||||
@@ -206,6 +208,20 @@ export type PolicyAuthProfileEvidence = {
|
||||
readonly mode?: string;
|
||||
};
|
||||
|
||||
export type PolicyDataHandlingEvidence = {
|
||||
readonly id: string;
|
||||
readonly kind:
|
||||
| "memorySessionTranscriptIndexing"
|
||||
| "sensitiveLoggingRedaction"
|
||||
| "sessionRetentionMode"
|
||||
| "telemetryContentCapture";
|
||||
readonly source: string;
|
||||
readonly scope: "global" | "agent";
|
||||
readonly agentId?: string;
|
||||
readonly value?: boolean | string;
|
||||
readonly explicit?: boolean;
|
||||
};
|
||||
|
||||
type SecretRefEvidence = {
|
||||
readonly source: "env" | "file" | "exec";
|
||||
readonly provider: string;
|
||||
@@ -280,6 +296,7 @@ export function collectPolicyEvidence(
|
||||
readonly includeIngress?: boolean;
|
||||
readonly includeGatewayExposure?: boolean;
|
||||
readonly includeAgentWorkspace?: boolean;
|
||||
readonly includeDataHandling?: boolean;
|
||||
readonly includeToolPosture?: boolean;
|
||||
readonly includeSandboxPosture?: boolean;
|
||||
readonly includeSecrets?: boolean;
|
||||
@@ -293,6 +310,7 @@ export function collectPolicyEvidence(
|
||||
readonly includeIngress?: boolean;
|
||||
readonly includeGatewayExposure?: boolean;
|
||||
readonly includeAgentWorkspace?: boolean;
|
||||
readonly includeDataHandling?: boolean;
|
||||
readonly includeToolPosture?: boolean;
|
||||
readonly includeSandboxPosture?: boolean;
|
||||
readonly includeSecrets?: boolean;
|
||||
@@ -306,6 +324,7 @@ export function collectPolicyEvidence(
|
||||
readonly includeIngress?: boolean;
|
||||
readonly includeGatewayExposure?: boolean;
|
||||
readonly includeAgentWorkspace?: boolean;
|
||||
readonly includeDataHandling?: boolean;
|
||||
readonly includeToolPosture?: boolean;
|
||||
readonly includeSandboxPosture?: boolean;
|
||||
readonly includeSecrets?: boolean;
|
||||
@@ -325,6 +344,7 @@ export function collectPolicyEvidence(
|
||||
...(options.includeAgentWorkspace === false
|
||||
? {}
|
||||
: { agentWorkspace: scanPolicyAgentWorkspace(cfg) }),
|
||||
...(options.includeDataHandling === false ? {} : { dataHandling: scanPolicyDataHandling(cfg) }),
|
||||
...(options.includeToolPosture === false ? {} : { toolPosture: scanPolicyToolPosture(cfg) }),
|
||||
...(options.includeSandboxPosture === false
|
||||
? {}
|
||||
@@ -795,6 +815,202 @@ export function scanPolicyAuthProfiles(
|
||||
});
|
||||
}
|
||||
|
||||
export function scanPolicyDataHandling(
|
||||
cfg: Record<string, unknown>,
|
||||
): readonly PolicyDataHandlingEvidence[] {
|
||||
const entries: PolicyDataHandlingEvidence[] = [];
|
||||
const logging = isRecord(cfg.logging) ? cfg.logging : {};
|
||||
entries.push({
|
||||
id: "logging-redaction",
|
||||
kind: "sensitiveLoggingRedaction",
|
||||
source: "oc://openclaw.config/logging/redactSensitive",
|
||||
scope: "global",
|
||||
value: logging.redactSensitive !== "off",
|
||||
explicit: logging.redactSensitive !== undefined,
|
||||
});
|
||||
|
||||
const diagnostics = isRecord(cfg.diagnostics) ? cfg.diagnostics : {};
|
||||
const otel = isRecord(diagnostics.otel) ? diagnostics.otel : {};
|
||||
const otelEnabled = diagnostics.enabled !== false && otel.enabled === true;
|
||||
const tracesEnabled = otelEnabled && otel.traces !== false;
|
||||
const logsEnabled = otelEnabled && otel.logs === true;
|
||||
const captureContent =
|
||||
otelEnabled &&
|
||||
telemetryContentCaptureEnabled(otel.captureContent, {
|
||||
tracesEnabled,
|
||||
logsEnabled,
|
||||
});
|
||||
entries.push({
|
||||
id: "diagnostics-otel-content-capture",
|
||||
kind: "telemetryContentCapture",
|
||||
source: "oc://openclaw.config/diagnostics/otel/captureContent",
|
||||
scope: "global",
|
||||
value: captureContent,
|
||||
explicit: otel.captureContent !== undefined,
|
||||
});
|
||||
|
||||
const session = isRecord(cfg.session) ? cfg.session : {};
|
||||
const maintenance = isRecord(session.maintenance) ? session.maintenance : {};
|
||||
const retentionMode = typeof maintenance.mode === "string" ? maintenance.mode : "enforce";
|
||||
entries.push({
|
||||
id: "session-maintenance-mode",
|
||||
kind: "sessionRetentionMode",
|
||||
source: "oc://openclaw.config/session/maintenance/mode",
|
||||
scope: "global",
|
||||
value: retentionMode,
|
||||
explicit: maintenance.mode !== undefined,
|
||||
});
|
||||
|
||||
pushMemorySessionTranscriptIndexing(entries, cfg);
|
||||
return entries.toSorted((a, b) => a.source.localeCompare(b.source));
|
||||
}
|
||||
|
||||
function telemetryContentCaptureEnabled(
|
||||
value: unknown,
|
||||
signals: { readonly tracesEnabled: boolean; readonly logsEnabled: boolean },
|
||||
): boolean {
|
||||
if (value === true) {
|
||||
return signals.tracesEnabled || signals.logsEnabled;
|
||||
}
|
||||
if (!isRecord(value)) {
|
||||
return false;
|
||||
}
|
||||
if (!signals.tracesEnabled) {
|
||||
return false;
|
||||
}
|
||||
if (value.enabled !== true) {
|
||||
return false;
|
||||
}
|
||||
return (
|
||||
value.inputMessages === true ||
|
||||
value.outputMessages === true ||
|
||||
value.toolInputs === true ||
|
||||
value.toolOutputs === true ||
|
||||
value.systemPrompt === true ||
|
||||
value.toolDefinitions === true
|
||||
);
|
||||
}
|
||||
|
||||
function pushMemorySessionTranscriptIndexing(
|
||||
entries: PolicyDataHandlingEvidence[],
|
||||
cfg: Record<string, unknown>,
|
||||
): void {
|
||||
const memory = isRecord(cfg.memory) ? cfg.memory : {};
|
||||
const qmd = isRecord(memory.qmd) ? memory.qmd : {};
|
||||
const qmdSessions = isRecord(qmd.sessions) ? qmd.sessions : {};
|
||||
if (qmdSessions.enabled !== undefined) {
|
||||
entries.push({
|
||||
id: "memory-qmd-session-transcripts",
|
||||
kind: "memorySessionTranscriptIndexing",
|
||||
source: "oc://openclaw.config/memory/qmd/sessions/enabled",
|
||||
scope: "global",
|
||||
value: memory.backend === "qmd" && readBoolean(qmdSessions.enabled) === true,
|
||||
explicit: true,
|
||||
});
|
||||
}
|
||||
|
||||
const agents = isRecord(cfg.agents) ? cfg.agents : {};
|
||||
const defaults = isRecord(agents.defaults) ? agents.defaults : {};
|
||||
const defaultsMemorySearch = isRecord(defaults.memorySearch) ? defaults.memorySearch : {};
|
||||
const defaultSessionMemory = memorySearchSessionTranscriptIndexing(defaultsMemorySearch);
|
||||
if (defaultSessionMemory !== undefined) {
|
||||
entries.push({
|
||||
id: "agents-defaults-memory-session-transcripts",
|
||||
kind: "memorySessionTranscriptIndexing",
|
||||
source: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory",
|
||||
scope: "global",
|
||||
value: defaultSessionMemory,
|
||||
explicit: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (!Array.isArray(agents.list)) {
|
||||
return;
|
||||
}
|
||||
agents.list.forEach((rawAgent, index) => {
|
||||
if (!isRecord(rawAgent)) {
|
||||
return;
|
||||
}
|
||||
const agentId =
|
||||
readString(rawAgent.id) ??
|
||||
readString(rawAgent.name) ??
|
||||
readString(rawAgent.slug) ??
|
||||
`agent-${index}`;
|
||||
const memorySearch = isRecord(rawAgent.memorySearch) ? rawAgent.memorySearch : undefined;
|
||||
const agentSessionMemory =
|
||||
memorySearch === undefined
|
||||
? defaultSessionMemory
|
||||
: memorySearchSessionTranscriptIndexing(memorySearch, defaultsMemorySearch);
|
||||
if (agentSessionMemory === undefined) {
|
||||
return;
|
||||
}
|
||||
const explicit = memorySearchSessionTranscriptIndexingHasLocalConfig(memorySearch);
|
||||
entries.push({
|
||||
id: `${agentId}-memory-session-transcripts`,
|
||||
kind: "memorySessionTranscriptIndexing",
|
||||
source: explicit
|
||||
? `oc://openclaw.config/agents/list/#${index}/memorySearch/experimental/sessionMemory`
|
||||
: "oc://openclaw.config/agents/defaults/memorySearch/experimental/sessionMemory",
|
||||
scope: "agent",
|
||||
agentId: normalizeAgentId(agentId),
|
||||
value: agentSessionMemory,
|
||||
explicit,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function memorySearchSessionTranscriptIndexing(
|
||||
memorySearch: unknown,
|
||||
inheritedMemorySearch?: unknown,
|
||||
): boolean | undefined {
|
||||
if (!isRecord(memorySearch)) {
|
||||
return undefined;
|
||||
}
|
||||
const experimental = isRecord(memorySearch.experimental) ? memorySearch.experimental : {};
|
||||
const inherited = isRecord(inheritedMemorySearch) ? inheritedMemorySearch : {};
|
||||
const inheritedExperimental = isRecord(inherited.experimental) ? inherited.experimental : {};
|
||||
const enabled = readBoolean(memorySearch.enabled) ?? readBoolean(inherited.enabled) ?? true;
|
||||
const sessionMemory =
|
||||
readBoolean(experimental.sessionMemory) ?? readBoolean(inheritedExperimental.sessionMemory);
|
||||
const sourcesIncludeSessions =
|
||||
memorySearchSourcesIncludeSessions(memorySearch) ??
|
||||
memorySearchSourcesIncludeSessions(inherited) ??
|
||||
false;
|
||||
if (
|
||||
sessionMemory === undefined &&
|
||||
memorySearchSourcesIncludeSessions(memorySearch) === undefined &&
|
||||
readBoolean(memorySearch.enabled) === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
return sessionMemory === true && sourcesIncludeSessions;
|
||||
}
|
||||
|
||||
function memorySearchSessionTranscriptIndexingHasLocalConfig(memorySearch: unknown): boolean {
|
||||
if (!isRecord(memorySearch)) {
|
||||
return false;
|
||||
}
|
||||
const experimental = isRecord(memorySearch.experimental) ? memorySearch.experimental : {};
|
||||
return (
|
||||
readBoolean(memorySearch.enabled) !== undefined ||
|
||||
readBoolean(experimental.sessionMemory) !== undefined ||
|
||||
memorySearchSourcesIncludeSessions(memorySearch) !== undefined
|
||||
);
|
||||
}
|
||||
|
||||
function memorySearchSourcesIncludeSessions(memorySearch: unknown): boolean | undefined {
|
||||
if (!isRecord(memorySearch) || memorySearch.sources === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
if (!Array.isArray(memorySearch.sources)) {
|
||||
return false;
|
||||
}
|
||||
return memorySearch.sources.includes("sessions");
|
||||
}
|
||||
|
||||
function scanPolicySecretProviders(cfg: Record<string, unknown>): readonly PolicySecretEvidence[] {
|
||||
const secrets = isRecord(cfg.secrets) ? cfg.secrets : {};
|
||||
const providers = isRecord(secrets.providers) ? secrets.providers : {};
|
||||
|
||||
@@ -1,7 +1,98 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { createQaBusState } from "./bus-state.js";
|
||||
import { readQaScenarioById } from "./scenario-catalog.js";
|
||||
import { runScenarioFlow } from "./scenario-flow-runner.js";
|
||||
|
||||
type QaFlowStep = {
|
||||
name: string;
|
||||
run: () => Promise<string | void>;
|
||||
};
|
||||
|
||||
function formatTestTranscript(state: ReturnType<typeof createQaBusState>) {
|
||||
return state
|
||||
.getSnapshot()
|
||||
.messages.map((message) => `${message.direction}:${message.conversation.id}:${message.text}`)
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
async function runLoadedScenarioFlow(
|
||||
scenarioId: string,
|
||||
params: {
|
||||
onWaitForOutboundMessage?: (params: {
|
||||
waitCount: number;
|
||||
state: ReturnType<typeof createQaBusState>;
|
||||
}) => void;
|
||||
} = {},
|
||||
) {
|
||||
const scenario = readQaScenarioById(scenarioId);
|
||||
const flow = scenario.execution.flow;
|
||||
if (!flow) {
|
||||
throw new Error(`scenario has no flow: ${scenarioId}`);
|
||||
}
|
||||
|
||||
const state = createQaBusState();
|
||||
let waitCount = 0;
|
||||
const api = {
|
||||
env: {},
|
||||
state,
|
||||
scenario,
|
||||
config: scenario.execution.config ?? {},
|
||||
randomUUID: () => "00000000-0000-4000-8000-000000000000",
|
||||
liveTurnTimeoutMs: (_env: unknown, timeoutMs: number) => timeoutMs,
|
||||
waitForGatewayHealthy: async () => undefined,
|
||||
waitForQaChannelReady: async () => undefined,
|
||||
waitForNoOutbound: async () => undefined,
|
||||
sleep: async () => undefined,
|
||||
reset: async () => {
|
||||
state.reset();
|
||||
},
|
||||
resetBus: async () => {
|
||||
state.reset();
|
||||
},
|
||||
runAgentPrompt: async () => undefined,
|
||||
formatTransportTranscript: formatTestTranscript,
|
||||
waitForOutboundMessage: async (
|
||||
stateLocal: ReturnType<typeof createQaBusState>,
|
||||
predicate: (candidate: unknown) => boolean,
|
||||
timeoutMs: number,
|
||||
options?: { sinceIndex?: number },
|
||||
) => {
|
||||
waitCount += 1;
|
||||
params.onWaitForOutboundMessage?.({ waitCount, state: stateLocal });
|
||||
const match = stateLocal
|
||||
.getSnapshot()
|
||||
.messages.slice(options?.sinceIndex ?? 0)
|
||||
.find((candidate) => predicate(candidate));
|
||||
if (match) {
|
||||
return match;
|
||||
}
|
||||
throw new Error(`timed out after ${timeoutMs}ms waiting for outbound marker`);
|
||||
},
|
||||
runScenario: async (_name: string, steps: QaFlowStep[]) => {
|
||||
const stepResults = [];
|
||||
for (const step of steps) {
|
||||
const details = await step.run();
|
||||
stepResults.push({
|
||||
name: step.name,
|
||||
status: "pass" as const,
|
||||
...(details !== undefined ? { details } : {}),
|
||||
});
|
||||
}
|
||||
return {
|
||||
name: scenario.title,
|
||||
status: "pass" as const,
|
||||
steps: stepResults,
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
return await runScenarioFlow({
|
||||
api,
|
||||
scenarioTitle: scenario.title,
|
||||
flow,
|
||||
});
|
||||
}
|
||||
|
||||
describe("scenario-flow-runner", () => {
|
||||
it("supports qaImport inside flow expressions", async () => {
|
||||
const result = await runScenarioFlow({
|
||||
@@ -221,4 +312,78 @@ describe("scenario-flow-runner", () => {
|
||||
expect(result.status).toBe("pass");
|
||||
expect(result.steps[0]?.details).toBe("QA_CODEX_PLUGIN_TURN_OK");
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
scenarioId: "channel-chat-baseline",
|
||||
to: "channel:qa-room",
|
||||
text: "generic shared-channel reply without the required marker",
|
||||
},
|
||||
{
|
||||
scenarioId: "dm-chat-baseline",
|
||||
to: "dm:alice",
|
||||
text: "generic DM reply without the required marker",
|
||||
},
|
||||
])("rejects unmarked outbound replies for $scenarioId", async ({ scenarioId, to, text }) => {
|
||||
await expect(
|
||||
runLoadedScenarioFlow(scenarioId, {
|
||||
onWaitForOutboundMessage: ({ state }) => {
|
||||
state.addOutboundMessage({
|
||||
accountId: "qa-channel",
|
||||
to,
|
||||
text,
|
||||
});
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("waiting for outbound marker");
|
||||
});
|
||||
|
||||
it("rejects reconnect follow-up replies that replay the first marker", async () => {
|
||||
await expect(
|
||||
runLoadedScenarioFlow("qa-channel-reconnect-dedupe", {
|
||||
onWaitForOutboundMessage: ({ waitCount, state }) => {
|
||||
if (waitCount === 1) {
|
||||
state.addOutboundMessage({
|
||||
accountId: "qa-channel",
|
||||
to: "channel:qa-room",
|
||||
text: "RECONNECT-FIRST-OK",
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.addOutboundMessage({
|
||||
accountId: "qa-channel",
|
||||
to: "channel:qa-room",
|
||||
text: "RECONNECT-FIRST-OK",
|
||||
});
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("waiting for outbound marker");
|
||||
});
|
||||
|
||||
it("rejects reconnect follow-up turns with extra unmarked outbound replies", async () => {
|
||||
await expect(
|
||||
runLoadedScenarioFlow("qa-channel-reconnect-dedupe", {
|
||||
onWaitForOutboundMessage: ({ waitCount, state }) => {
|
||||
if (waitCount === 1) {
|
||||
state.addOutboundMessage({
|
||||
accountId: "qa-channel",
|
||||
to: "channel:qa-room",
|
||||
text: "RECONNECT-FIRST-OK",
|
||||
});
|
||||
return;
|
||||
}
|
||||
state.addOutboundMessage({
|
||||
accountId: "qa-channel",
|
||||
to: "channel:qa-room",
|
||||
text: "RECONNECT-SECOND-OK",
|
||||
});
|
||||
state.addOutboundMessage({
|
||||
accountId: "qa-channel",
|
||||
to: "channel:qa-room",
|
||||
text: "unmarked duplicate delivery",
|
||||
});
|
||||
},
|
||||
}),
|
||||
).rejects.toThrow("exactly one marked post-restart reply");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -227,6 +227,7 @@ const telegramDepsForTest: TelegramBotDeps = {
|
||||
|
||||
describe("dispatchTelegramMessage draft streaming", () => {
|
||||
type TelegramMessageContext = Parameters<typeof dispatchTelegramMessage>[0]["context"];
|
||||
const trailingFinalStatusText = "Post-final plugin status";
|
||||
|
||||
beforeAll(async () => {
|
||||
({ dispatchTelegramMessage, resetTelegramReplyFenceForTests } =
|
||||
@@ -1605,6 +1606,30 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(editMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends trailing verbose status after streamed final answer without replacing the answer draft", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onPartialReply?.({ text: "Normal reply" });
|
||||
await dispatcherOptions.deliver({ text: "Normal reply" }, { kind: "final" });
|
||||
await dispatcherOptions.deliver({ text: trailingFinalStatusText }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({ context: createContext() });
|
||||
|
||||
expect(answerDraftStream.update).toHaveBeenCalledTimes(3);
|
||||
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Normal reply");
|
||||
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, "Normal reply");
|
||||
expect(answerDraftStream.update).toHaveBeenNthCalledWith(3, trailingFinalStatusText);
|
||||
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(1);
|
||||
expect(answerDraftStream.forceNewMessage.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
answerDraftStream.update.mock.invocationCallOrder[2],
|
||||
);
|
||||
expect(deliverReplies).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("applies partial deltas while preserving the first-preview debounce", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
@@ -2075,6 +2100,33 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(editMessageTelegram).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends trailing verbose status after a progress-mode final answer", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await dispatcherOptions.deliver({ text: "Branch is up to date" }, { kind: "final" });
|
||||
await dispatcherOptions.deliver({ text: trailingFinalStatusText }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: { streaming: { mode: "progress" } },
|
||||
});
|
||||
|
||||
expect(answerDraftStream.update).toHaveBeenCalledTimes(2);
|
||||
expect(answerDraftStream.update).toHaveBeenNthCalledWith(1, "Cracking\n\n`🛠️ Exec`");
|
||||
expect(answerDraftStream.update).toHaveBeenNthCalledWith(2, trailingFinalStatusText);
|
||||
expect(answerDraftStream.forceNewMessage).toHaveBeenCalledTimes(2);
|
||||
expect(answerDraftStream.forceNewMessage.mock.invocationCallOrder[1]).toBeLessThan(
|
||||
answerDraftStream.update.mock.invocationCallOrder[1],
|
||||
);
|
||||
expectDeliveredReply(0, { text: "Branch is up to date" });
|
||||
});
|
||||
|
||||
it("does not stream text-only tool results into progress drafts", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
@@ -2317,6 +2369,81 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
expect(draftStream.flush).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("composes streamed reasoning with tool progress in Telegram progress drafts", async () => {
|
||||
const draftStream = createSequencedDraftStream(2001);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
|
||||
await replyOptions?.onReplyStart?.();
|
||||
await replyOptions?.onAssistantMessageStart?.();
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await replyOptions?.onReasoningStream?.({ text: "<think>Checking files</think>" });
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createReasoningStreamContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: { streaming: { mode: "progress", progress: { label: "Shelling" } } },
|
||||
});
|
||||
|
||||
expect(createTelegramDraftStream).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n`🛠️ Exec`\n• _Checking files_");
|
||||
});
|
||||
|
||||
it("renders configured Telegram commentary progress from preamble item events", async () => {
|
||||
const draftStream = createSequencedDraftStream(2001);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
|
||||
await replyOptions?.onReplyStart?.();
|
||||
await replyOptions?.onItemEvent?.({
|
||||
kind: "preamble",
|
||||
itemId: "preamble-1",
|
||||
progressText: "Checking recent context",
|
||||
});
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: { label: "Shelling", commentary: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(draftStream.update).toHaveBeenCalledWith("Shelling\n\n_Checking recent context_");
|
||||
});
|
||||
|
||||
it("suppresses Telegram preamble progress when commentary is disabled", async () => {
|
||||
const draftStream = createSequencedDraftStream(2001);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ replyOptions }) => {
|
||||
await replyOptions?.onReplyStart?.();
|
||||
await replyOptions?.onItemEvent?.({
|
||||
kind: "preamble",
|
||||
itemId: "preamble-1",
|
||||
progressText: "Checking recent context",
|
||||
});
|
||||
return { queuedFinal: false };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "progress",
|
||||
telegramCfg: {
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: { label: "Shelling" },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(draftStream.update).not.toHaveBeenCalledWith(expect.stringContaining("Checking recent"));
|
||||
});
|
||||
|
||||
it("keeps the progress draft label when tool progress lines are hidden", async () => {
|
||||
const draftStream = createSequencedDraftStream(2001);
|
||||
createTelegramDraftStream.mockReturnValue(draftStream);
|
||||
@@ -3446,6 +3573,150 @@ describe("dispatchTelegramMessage draft streaming", () => {
|
||||
await sidePromise;
|
||||
});
|
||||
|
||||
it("does not drop the first chunk of a long final after a generic lane rotation", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await dispatcherOptions.deliver(
|
||||
{ text: "A".repeat(4000) + "B".repeat(4000) },
|
||||
{ kind: "final" },
|
||||
);
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
textLimit: 4000,
|
||||
});
|
||||
|
||||
expect(answerDraftStream.update).toHaveBeenCalledWith("A".repeat(4000));
|
||||
});
|
||||
|
||||
it("does not suppress text-only blocks as delivered when answer draft is inactive", async () => {
|
||||
setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(async ({ dispatcherOptions }) => {
|
||||
await dispatcherOptions.deliver({ text: "forced block" }, { kind: "block" });
|
||||
await dispatcherOptions.deliver({ text: "final text" }, { kind: "final" });
|
||||
return { queuedFinal: true };
|
||||
});
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: {
|
||||
streaming: { mode: "partial", block: { enabled: true } },
|
||||
} satisfies Parameters<typeof dispatchTelegramMessage>[0]["telegramCfg"],
|
||||
});
|
||||
|
||||
const deliveredTexts = deliverReplies.mock.calls.flatMap((call) =>
|
||||
((call[0] as { replies?: Array<{ text?: string }> }).replies ?? []).map(
|
||||
(reply) => reply.text,
|
||||
),
|
||||
);
|
||||
expect(deliveredTexts).toContain("forced block");
|
||||
});
|
||||
|
||||
it("does not suppress text-only blocks after a tool-progress draft", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onToolStart?.({ name: "exec", phase: "start" });
|
||||
await dispatcherOptions.deliver({ text: "block after progress" }, { kind: "block" });
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
});
|
||||
|
||||
expect(mockCallArg(answerDraftStream.update)).toContain("Exec");
|
||||
expect(answerDraftStream.update).toHaveBeenLastCalledWith("block after progress");
|
||||
});
|
||||
|
||||
it("does not suppress button-bearing blocks after answer streaming starts", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
const buttons = [[{ text: "OK", callback_data: "ok" }]];
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onPartialReply?.({ text: "partial answer" });
|
||||
await dispatcherOptions.deliver(
|
||||
{ text: "choose now", channelData: { telegram: { buttons } } },
|
||||
{ kind: "block" },
|
||||
);
|
||||
return { queuedFinal: true };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
});
|
||||
|
||||
expect(answerDraftStream.update).toHaveBeenLastCalledWith("choose now");
|
||||
expectRecordFields(mockCallArg(editMessageTelegram, 0, 3), { buttons });
|
||||
});
|
||||
|
||||
it("finalizes a duplicate text-only block when no final follows", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams({ answerMessageId: 2001 });
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onPartialReply?.({ text: "partial answer" });
|
||||
await dispatcherOptions.deliver({ text: "partial answer" }, { kind: "block" });
|
||||
return { queuedFinal: false };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
});
|
||||
|
||||
expect(answerDraftStream.stop).toHaveBeenCalled();
|
||||
expect(answerDraftStream.clear).not.toHaveBeenCalled();
|
||||
expectRecordFields(mockCallArg(emitInternalMessageSentHook), {
|
||||
content: "partial answer",
|
||||
messageId: 2001,
|
||||
});
|
||||
expectRecordFields(mockCallArg(recordOutboundMessageForPromptContext), {
|
||||
text: "partial answer",
|
||||
messageId: 2001,
|
||||
});
|
||||
});
|
||||
|
||||
it("materializes a pending duplicate text-only block before finalizing it", async () => {
|
||||
const { answerDraftStream } = setupDraftStreams();
|
||||
answerDraftStream.stop.mockImplementation(async () => {
|
||||
answerDraftStream.setMessageId(2001);
|
||||
});
|
||||
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
|
||||
async ({ dispatcherOptions, replyOptions }) => {
|
||||
await replyOptions?.onPartialReply?.({ text: "pending answer" });
|
||||
await dispatcherOptions.deliver({ text: "pending answer" }, { kind: "block" });
|
||||
return { queuedFinal: false };
|
||||
},
|
||||
);
|
||||
|
||||
await dispatchWithContext({
|
||||
context: createContext(),
|
||||
streamMode: "partial",
|
||||
telegramCfg: { streaming: { mode: "partial" } },
|
||||
});
|
||||
|
||||
expect(answerDraftStream.stop).toHaveBeenCalled();
|
||||
expect(answerDraftStream.clear).not.toHaveBeenCalled();
|
||||
expectRecordFields(mockCallArg(emitInternalMessageSentHook), {
|
||||
content: "pending answer",
|
||||
messageId: 2001,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps queued room events abortable after their source dispatch returns", async () => {
|
||||
const historyKey = "telegram:group:-100123";
|
||||
const groupHistories = new Map([[historyKey, []]]);
|
||||
|
||||
@@ -19,19 +19,16 @@ import { CURRENT_MESSAGE_MARKER } from "openclaw/plugin-sdk/channel-mention-gati
|
||||
import {
|
||||
createChannelMessageReplyPipeline,
|
||||
createOutboundPayloadPlan,
|
||||
createPreviewMessageReceipt,
|
||||
deriveDurableFinalDeliveryRequirements,
|
||||
projectOutboundPayloadPlanForDelivery,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
buildChannelProgressDraftLineForEntry,
|
||||
createChannelProgressDraftGate,
|
||||
type ChannelProgressDraftLine,
|
||||
createChannelProgressDraftCompositor,
|
||||
formatChannelProgressDraftLine,
|
||||
formatChannelProgressDraftLineForEntry,
|
||||
formatChannelProgressDraftText,
|
||||
isChannelProgressDraftWorkToolName,
|
||||
mergeChannelProgressDraftLine,
|
||||
resolveChannelProgressDraftMaxLines,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingPreviewNativeToolProgress,
|
||||
resolveChannelStreamingPreviewNativeToolProgressAllowFrom,
|
||||
@@ -408,6 +405,13 @@ function formatProgressAsMarkdownCode(text: string): string {
|
||||
return `\`${sanitizeProgressMarkdownText(clipped)}\``;
|
||||
}
|
||||
|
||||
function formatTelegramProgressLine(text: string): string {
|
||||
const trimmed = text.trim();
|
||||
return trimmed.startsWith("_") && trimmed.endsWith("_")
|
||||
? trimmed
|
||||
: formatProgressAsMarkdownCode(text);
|
||||
}
|
||||
|
||||
function normalizeTelegramThreadId(value: unknown): number | undefined {
|
||||
return parseStrictPositiveInteger(value);
|
||||
}
|
||||
@@ -873,7 +877,10 @@ export const dispatchTelegramMessage = async ({
|
||||
!hasTelegramQuoteReply &&
|
||||
!accountBlockStreamingEnabled &&
|
||||
!forceBlockStreamingForReasoning;
|
||||
const canStreamReasoningDraft = !isRoomEvent && streamReasoningDraft;
|
||||
const streamReasoningInProgressDraft =
|
||||
streamReasoningDraft && streamMode === "progress" && canStreamAnswerDraft;
|
||||
const canStreamReasoningDraft =
|
||||
!isRoomEvent && streamReasoningDraft && !streamReasoningInProgressDraft;
|
||||
const draftReplyToMessageId =
|
||||
replyToMode !== "off" && typeof msg.message_id === "number"
|
||||
? (replyQuoteMessageId ?? msg.message_id)
|
||||
@@ -893,6 +900,7 @@ export const dispatchTelegramMessage = async ({
|
||||
renderText: renderStreamText,
|
||||
onSupersededPreview: (superseded) => {
|
||||
if (superseded.retain) {
|
||||
lanes[laneName].activeChunkIndex += 1;
|
||||
return;
|
||||
}
|
||||
void bot.api.deleteMessage(chatId, superseded.messageId).catch((err: unknown) => {
|
||||
@@ -910,6 +918,7 @@ export const dispatchTelegramMessage = async ({
|
||||
lastPartialText: "",
|
||||
hasStreamedMessage: false,
|
||||
finalized: false,
|
||||
activeChunkIndex: 0,
|
||||
};
|
||||
};
|
||||
const lanes: Record<LaneName, DraftLaneState> = {
|
||||
@@ -936,8 +945,6 @@ export const dispatchTelegramMessage = async ({
|
||||
log: logVerbose,
|
||||
})
|
||||
: undefined;
|
||||
let streamToolProgressSuppressed = false;
|
||||
let streamToolProgressLines: Array<string | ChannelProgressDraftLine> = [];
|
||||
let lastAnswerPartialText = "";
|
||||
let activeAnswerDraftIsToolProgressOnly = false;
|
||||
function resetAnswerToolProgressDraft() {
|
||||
@@ -952,33 +959,25 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
activeAnswerDraftIsToolProgressOnly = true;
|
||||
}
|
||||
const renderProgressDraft = async (options?: { flush?: boolean }): Promise<boolean> => {
|
||||
if (!answerLane.stream || streamMode !== "progress") {
|
||||
return false;
|
||||
}
|
||||
const streamText = formatChannelProgressDraftText({
|
||||
entry: telegramCfg,
|
||||
lines: streamToolProgressLines,
|
||||
seed: progressSeed,
|
||||
formatLine: formatProgressAsMarkdownCode,
|
||||
});
|
||||
if (!streamText || streamText === answerLane.lastPartialText) {
|
||||
return false;
|
||||
}
|
||||
await prepareAnswerLaneForToolProgress();
|
||||
answerLane.lastPartialText = streamText;
|
||||
answerLane.hasStreamedMessage = true;
|
||||
answerLane.finalized = false;
|
||||
answerLane.stream.update(streamText);
|
||||
if (options?.flush) {
|
||||
await answerLane.stream.flush();
|
||||
}
|
||||
return true;
|
||||
};
|
||||
const progressDraftGate = createChannelProgressDraftGate({
|
||||
onStart: async () => {
|
||||
await renderProgressDraft({ flush: true });
|
||||
const progressDraft = createChannelProgressDraftCompositor({
|
||||
entry: telegramCfg,
|
||||
mode: streamMode,
|
||||
active: Boolean(answerLane.stream),
|
||||
seed: progressSeed,
|
||||
formatLine: formatTelegramProgressLine,
|
||||
update: async (streamText, options) => {
|
||||
await prepareAnswerLaneForToolProgress();
|
||||
answerLane.lastPartialText = streamText;
|
||||
answerLane.hasStreamedMessage = true;
|
||||
answerLane.finalized = false;
|
||||
answerLane.stream?.update(streamText);
|
||||
if (options?.flush) {
|
||||
await answerLane.stream?.flush();
|
||||
}
|
||||
},
|
||||
tryNativeUpdate: nativeToolProgressDraft
|
||||
? async (streamText) => await nativeToolProgressDraft.update(streamText)
|
||||
: undefined,
|
||||
});
|
||||
let finalAnswerDeliveryStarted = false;
|
||||
let finalAnswerDelivered = false;
|
||||
@@ -986,80 +985,37 @@ export const dispatchTelegramMessage = async ({
|
||||
line?: string | ChannelProgressDraftLine,
|
||||
options?: { toolName?: string; startImmediately?: boolean },
|
||||
) => {
|
||||
if (!answerLane.stream) {
|
||||
if (
|
||||
!answerLane.stream ||
|
||||
answerLane.finalized ||
|
||||
finalAnswerDeliveryStarted ||
|
||||
finalAnswerDelivered
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (answerLane.finalized || finalAnswerDeliveryStarted || finalAnswerDelivered) {
|
||||
return false;
|
||||
}
|
||||
if (options?.toolName !== undefined && !isChannelProgressDraftWorkToolName(options.toolName)) {
|
||||
return false;
|
||||
}
|
||||
const rawText = typeof line === "string" ? line : line?.text;
|
||||
const normalized = sanitizeProgressMarkdownText(rawText?.replace(/\s+/g, " ").trim() ?? "");
|
||||
if (streamToolProgressSuppressed) {
|
||||
return false;
|
||||
}
|
||||
if (streamMode !== "progress" && !streamToolProgressEnabled) {
|
||||
return false;
|
||||
}
|
||||
const shouldUpdateProgressLines =
|
||||
streamToolProgressEnabled && !streamToolProgressSuppressed && Boolean(normalized);
|
||||
if (!shouldUpdateProgressLines && streamMode !== "progress") {
|
||||
return false;
|
||||
}
|
||||
const progressLine =
|
||||
typeof line === "object" && line !== undefined ? { ...line, text: normalized } : normalized;
|
||||
const nextLines = shouldUpdateProgressLines
|
||||
? mergeChannelProgressDraftLine(streamToolProgressLines, progressLine, {
|
||||
maxLines: resolveChannelProgressDraftMaxLines(telegramCfg),
|
||||
})
|
||||
: streamToolProgressLines;
|
||||
if (shouldUpdateProgressLines && nextLines === streamToolProgressLines) {
|
||||
return false;
|
||||
}
|
||||
if (nativeToolProgressDraft && shouldUpdateProgressLines) {
|
||||
const streamText = formatChannelProgressDraftText({
|
||||
entry: telegramCfg,
|
||||
lines: nextLines,
|
||||
seed: progressSeed,
|
||||
});
|
||||
if (streamText && (await nativeToolProgressDraft.update(streamText))) {
|
||||
streamToolProgressLines = nextLines;
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (streamMode !== "progress") {
|
||||
streamToolProgressLines = nextLines;
|
||||
const streamText = formatChannelProgressDraftText({
|
||||
entry: telegramCfg,
|
||||
lines: streamToolProgressLines,
|
||||
seed: progressSeed,
|
||||
formatLine: formatProgressAsMarkdownCode,
|
||||
});
|
||||
await prepareAnswerLaneForToolProgress();
|
||||
answerLane.lastPartialText = streamText;
|
||||
answerLane.hasStreamedMessage = true;
|
||||
answerLane.finalized = false;
|
||||
answerLane.stream.update(streamText);
|
||||
return true;
|
||||
}
|
||||
streamToolProgressLines = nextLines;
|
||||
if (options?.startImmediately) {
|
||||
await progressDraftGate.startNow();
|
||||
if (progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
return true;
|
||||
}
|
||||
return progressDraftGate.hasStarted;
|
||||
}
|
||||
const alreadyStarted = progressDraftGate.hasStarted;
|
||||
const progressActive = await progressDraftGate.noteWork();
|
||||
if ((alreadyStarted || progressActive) && progressDraftGate.hasStarted) {
|
||||
await renderProgressDraft();
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
return await progressDraft.pushToolProgress(line, options);
|
||||
};
|
||||
const pushStreamReasoningProgress = async (payload: {
|
||||
text?: string;
|
||||
isReasoningSnapshot?: boolean;
|
||||
}) => {
|
||||
return await progressDraft.pushReasoningProgress(payload.text, {
|
||||
snapshot: payload.isReasoningSnapshot === true,
|
||||
});
|
||||
};
|
||||
const markProgressFinalStarted = () => {
|
||||
finalAnswerDeliveryStarted = true;
|
||||
progressDraft.markFinalReplyStarted();
|
||||
};
|
||||
const markProgressFinalDelivered = () => {
|
||||
finalAnswerDelivered = true;
|
||||
progressDraft.markFinalReplyDelivered();
|
||||
};
|
||||
const resetProgressDraftState = () => {
|
||||
progressDraft.reset();
|
||||
};
|
||||
const suppressProgressDraftState = () => {
|
||||
progressDraft.suppress();
|
||||
};
|
||||
let splitReasoningOnNextStream = false;
|
||||
let draftLaneEventQueue = Promise.resolve();
|
||||
@@ -1122,6 +1078,7 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
lane.hasStreamedMessage = false;
|
||||
lane.finalized = false;
|
||||
lane.activeChunkIndex = 0;
|
||||
if (lane === answerLane) {
|
||||
resetAnswerToolProgressDraft();
|
||||
}
|
||||
@@ -1143,8 +1100,7 @@ export const dispatchTelegramMessage = async ({
|
||||
await answerLane.stream?.clear();
|
||||
answerLane.stream?.forceNewMessage();
|
||||
resetDraftLaneState(answerLane);
|
||||
streamToolProgressSuppressed = true;
|
||||
streamToolProgressLines = [];
|
||||
suppressProgressDraftState();
|
||||
return true;
|
||||
};
|
||||
const prepareAnswerLaneForText = async () => {
|
||||
@@ -1172,8 +1128,7 @@ export const dispatchTelegramMessage = async ({
|
||||
return;
|
||||
}
|
||||
resetAnswerToolProgressDraft();
|
||||
streamToolProgressSuppressed = true;
|
||||
streamToolProgressLines = [];
|
||||
suppressProgressDraftState();
|
||||
}
|
||||
lane.hasStreamedMessage = true;
|
||||
lane.finalized = false;
|
||||
@@ -1342,6 +1297,7 @@ export const dispatchTelegramMessage = async ({
|
||||
const silentErrorReplies = telegramCfg.silentErrorReplies === true;
|
||||
const isDmTopic = !isGroup && threadSpec.scope === "dm" && threadSpec.id != null;
|
||||
let queuedFinal = false;
|
||||
let skippedDuplicateAnswerBlockDraftDelivery = false;
|
||||
let suppressSilentReplyFallback = false;
|
||||
let hadErrorReplyFailureOrSkip = false;
|
||||
let isFirstTurnInSession = false;
|
||||
@@ -1540,6 +1496,43 @@ export const dispatchTelegramMessage = async ({
|
||||
});
|
||||
}
|
||||
};
|
||||
const finalizeSkippedDuplicateAnswerBlockDraft = async () => {
|
||||
if (
|
||||
!skippedDuplicateAnswerBlockDraftDelivery ||
|
||||
queuedFinal ||
|
||||
dispatchError ||
|
||||
isDispatchSuperseded() ||
|
||||
answerLane.finalized
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const stream = answerLane.stream;
|
||||
const content = answerLane.lastPartialText;
|
||||
if (!stream || !content) {
|
||||
return;
|
||||
}
|
||||
await stream.stop();
|
||||
const messageId = stream.messageId();
|
||||
if (typeof messageId !== "number") {
|
||||
if (stream.sendMayHaveLanded?.()) {
|
||||
answerLane.finalized = true;
|
||||
deliveryState.markDelivered();
|
||||
}
|
||||
return;
|
||||
}
|
||||
answerLane.finalized = true;
|
||||
deliveryState.markDelivered();
|
||||
await emitPreviewFinalizedHook({
|
||||
kind: "preview-finalized",
|
||||
delivery: {
|
||||
content,
|
||||
promptContextContent: content,
|
||||
messageId,
|
||||
buttonsAttached: false,
|
||||
receipt: createPreviewMessageReceipt({ id: messageId }),
|
||||
},
|
||||
});
|
||||
};
|
||||
const deliverLaneText = createLaneTextDeliverer({
|
||||
lanes,
|
||||
draftMaxChars,
|
||||
@@ -1587,7 +1580,7 @@ export const dispatchTelegramMessage = async ({
|
||||
return { kind: "skipped" };
|
||||
}
|
||||
answerLane.finalized = true;
|
||||
finalAnswerDelivered = true;
|
||||
markProgressFinalDelivered();
|
||||
return { kind: "sent" };
|
||||
};
|
||||
const resolveTranscriptBackedFinalText = async (text: string): Promise<string> =>
|
||||
@@ -1702,7 +1695,7 @@ export const dispatchTelegramMessage = async ({
|
||||
const segments = split.segments;
|
||||
const reply = resolveSendableOutboundReplyParts(effectivePayload);
|
||||
if (info.kind === "final" && (reply.text.length > 0 || reply.hasMedia)) {
|
||||
finalAnswerDeliveryStarted = true;
|
||||
markProgressFinalStarted();
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
await enqueueDraftLaneEvent(async () => {});
|
||||
@@ -1722,6 +1715,19 @@ export const dispatchTelegramMessage = async ({
|
||||
buttons?: TelegramInlineButtons,
|
||||
) => {
|
||||
const finalText = await resolveTranscriptBackedFinalText(text);
|
||||
const deliverPostFinalFollowUpText = async () => {
|
||||
await prepareAnswerLaneForText();
|
||||
return deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: finalText,
|
||||
payload: answerPayload,
|
||||
infoKind: "final",
|
||||
buttons,
|
||||
});
|
||||
};
|
||||
if (finalAnswerDelivered) {
|
||||
return deliverPostFinalFollowUpText();
|
||||
}
|
||||
if (streamMode === "progress") {
|
||||
return deliverProgressModeFinalAnswer(answerPayload, finalText);
|
||||
}
|
||||
@@ -1734,7 +1740,7 @@ export const dispatchTelegramMessage = async ({
|
||||
buttons,
|
||||
});
|
||||
if (result.kind !== "skipped") {
|
||||
finalAnswerDelivered = true;
|
||||
markProgressFinalDelivered();
|
||||
}
|
||||
return result;
|
||||
};
|
||||
@@ -1796,6 +1802,24 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
await prepareAnswerLaneForToolProgress();
|
||||
}
|
||||
|
||||
const skipTextOnlyBlock =
|
||||
streamMode === "partial" &&
|
||||
info.kind === "block" &&
|
||||
segment.lane === "answer" &&
|
||||
!reply.hasMedia &&
|
||||
!hasExecApprovalPayload(effectivePayload) &&
|
||||
telegramButtons === undefined &&
|
||||
answerLane.hasStreamedMessage &&
|
||||
!activeAnswerDraftIsToolProgressOnly &&
|
||||
segment.update.text.trimEnd() === answerLane.lastPartialText.trimEnd();
|
||||
|
||||
if (skipTextOnlyBlock) {
|
||||
skippedDuplicateAnswerBlockDraftDelivery = true;
|
||||
blockDelivered = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
const result =
|
||||
segment.lane === "answer" && info.kind === "final"
|
||||
? await deliverFinalAnswerText(
|
||||
@@ -1849,7 +1873,7 @@ export const dispatchTelegramMessage = async ({
|
||||
});
|
||||
}
|
||||
if (info.kind === "final" && delivered) {
|
||||
finalAnswerDelivered = true;
|
||||
markProgressFinalDelivered();
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
await flushBufferedFinalAnswer();
|
||||
@@ -1875,7 +1899,7 @@ export const dispatchTelegramMessage = async ({
|
||||
durable: info.kind === "final",
|
||||
});
|
||||
if (info.kind === "final" && delivered) {
|
||||
finalAnswerDelivered = true;
|
||||
markProgressFinalDelivered();
|
||||
}
|
||||
if (info.kind === "final") {
|
||||
await flushBufferedFinalAnswer();
|
||||
@@ -1957,13 +1981,20 @@ export const dispatchTelegramMessage = async ({
|
||||
}
|
||||
await ingestDraftLaneSegments(payload, true);
|
||||
})
|
||||
: undefined,
|
||||
: streamReasoningInProgressDraft
|
||||
? (payload) =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
await pushStreamReasoningProgress(payload);
|
||||
})
|
||||
: undefined,
|
||||
onAssistantMessageStart: answerLane.stream
|
||||
? () =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
reasoningStepState.resetForNextStep();
|
||||
streamToolProgressSuppressed = false;
|
||||
streamToolProgressLines = [];
|
||||
finalAnswerDelivered = false;
|
||||
if (streamMode !== "progress") {
|
||||
resetProgressDraftState();
|
||||
}
|
||||
if (answerLane.finalized) {
|
||||
await rotateLaneForNewMessage(answerLane);
|
||||
}
|
||||
@@ -1973,8 +2004,7 @@ export const dispatchTelegramMessage = async ({
|
||||
? () =>
|
||||
enqueueDraftLaneEvent(async () => {
|
||||
splitReasoningOnNextStream = reasoningLane.hasStreamedMessage;
|
||||
streamToolProgressSuppressed = false;
|
||||
streamToolProgressLines = [];
|
||||
resetProgressDraftState();
|
||||
})
|
||||
: undefined,
|
||||
suppressDefaultToolProgressMessages:
|
||||
@@ -2002,6 +2032,12 @@ export const dispatchTelegramMessage = async ({
|
||||
await progressPromise;
|
||||
},
|
||||
onItemEvent: async (payload) => {
|
||||
if (payload.kind === "preamble") {
|
||||
await progressDraft.pushCommentaryProgress(payload.progressText, {
|
||||
itemId: payload.itemId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
await pushStreamToolProgress(
|
||||
buildChannelProgressDraftLineForEntry(telegramCfg, {
|
||||
event: "item",
|
||||
@@ -2106,9 +2142,10 @@ export const dispatchTelegramMessage = async ({
|
||||
dispatchError = err;
|
||||
runtime.error?.(danger(`telegram dispatch failed: ${String(err)}`));
|
||||
} finally {
|
||||
progressDraftGate.cancel();
|
||||
progressDraft.cancel();
|
||||
await draftLaneEventQueue;
|
||||
nativeToolProgressDraft?.stop();
|
||||
await finalizeSkippedDuplicateAnswerBlockDraft();
|
||||
const lanesToCleanup: Array<{ laneName: LaneName; lane: DraftLaneState }> = [
|
||||
{ laneName: "answer", lane: answerLane },
|
||||
{ laneName: "reasoning", lane: reasoningLane },
|
||||
|
||||
@@ -106,6 +106,22 @@ describe("telegram custom commands schema", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("accepts Telegram progress commentary config", () => {
|
||||
expectTelegramConfigValid({
|
||||
streaming: {
|
||||
mode: "progress",
|
||||
progress: { commentary: true },
|
||||
},
|
||||
accounts: {
|
||||
ops: {
|
||||
streaming: {
|
||||
progress: { commentary: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("rejects removed DM thread reply policy keys", () => {
|
||||
expectTelegramConfigIssue({ dm: { threadReplies: "off" } }, "");
|
||||
expectTelegramConfigIssue(
|
||||
|
||||
@@ -109,6 +109,10 @@ export const telegramChannelConfigUiHints = {
|
||||
label: "Telegram Progress Command Text",
|
||||
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
|
||||
},
|
||||
"streaming.progress.commentary": {
|
||||
label: "Telegram Progress Commentary",
|
||||
help: "Show assistant commentary/preamble text in the temporary progress draft. Final answer delivery is unchanged.",
|
||||
},
|
||||
"retry.attempts": {
|
||||
label: "Telegram Retry Attempts",
|
||||
help: "Max retry attempts for outbound Telegram API calls (default: 3).",
|
||||
|
||||
@@ -315,7 +315,7 @@ describe("createTelegramDraftStream", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not rebind to an old message when forceNewMessage races an in-flight send", async () => {
|
||||
it("retains an old message when forceNewMessage races an in-flight send", async () => {
|
||||
let resolveFirstSend: ((value: { message_id: number }) => void) | undefined;
|
||||
const firstSend = new Promise<{ message_id: number }>((resolve) => {
|
||||
resolveFirstSend = resolve;
|
||||
@@ -326,16 +326,11 @@ describe("createTelegramDraftStream", () => {
|
||||
deleteMessage: vi.fn().mockResolvedValue(true),
|
||||
};
|
||||
const onSupersededPreview = vi.fn();
|
||||
const stream = createTelegramDraftStream({
|
||||
api: api as unknown as Bot["api"],
|
||||
chatId: 123,
|
||||
onSupersededPreview,
|
||||
});
|
||||
const stream = createDraftStream(api, { onSupersededPreview });
|
||||
|
||||
stream.update("Message A partial");
|
||||
await vi.waitFor(() => expect(api.sendMessage).toHaveBeenCalledTimes(1));
|
||||
|
||||
// Rotate to message B before message A send resolves.
|
||||
stream.forceNewMessage();
|
||||
stream.update("Message B partial");
|
||||
|
||||
@@ -349,6 +344,7 @@ describe("createTelegramDraftStream", () => {
|
||||
textSnapshot: "Message A partial",
|
||||
parseMode: undefined,
|
||||
visibleSinceMs: supersededPreview.visibleSinceMs,
|
||||
retain: true,
|
||||
});
|
||||
expect(typeof supersededPreview.visibleSinceMs).toBe("number");
|
||||
expect(Number.isFinite(supersededPreview.visibleSinceMs)).toBe(true);
|
||||
|
||||
@@ -177,6 +177,7 @@ export function createTelegramDraftStream(params: {
|
||||
textSnapshot: renderedText,
|
||||
parseMode: renderedParseMode,
|
||||
visibleSinceMs,
|
||||
retain: true,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export type DraftLaneState = {
|
||||
lastPartialText: string;
|
||||
hasStreamedMessage: boolean;
|
||||
finalized: boolean;
|
||||
activeChunkIndex: number;
|
||||
};
|
||||
|
||||
type LanePreviewFinalizedDelivery = {
|
||||
@@ -275,11 +276,19 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
text.length > params.draftMaxChars
|
||||
? compactChunks(params.splitFinalTextForStream?.(text) ?? [])
|
||||
: [text];
|
||||
const [firstChunk, ...remainingChunks] = chunks;
|
||||
if (!firstChunk || firstChunk.length > params.draftMaxChars) {
|
||||
|
||||
const clampActiveChunkIndex = () =>
|
||||
Math.min(lane.activeChunkIndex, Math.max(0, chunks.length - 1));
|
||||
const activeChunkIndex = clampActiveChunkIndex();
|
||||
const activeChunk = chunks[activeChunkIndex];
|
||||
const remainingChunks = chunks.slice(activeChunkIndex + 1);
|
||||
|
||||
if (!activeChunk || activeChunk.length > params.draftMaxChars) {
|
||||
return undefined;
|
||||
}
|
||||
const finalText = text.trimEnd();
|
||||
|
||||
const activeFullText = chunks.slice(activeChunkIndex).join("");
|
||||
const finalText = activeFullText.trimEnd();
|
||||
const deliveredStreamTextBeforeUpdate = stream.lastDeliveredText?.();
|
||||
const deliveredPrefixBeforeUpdate =
|
||||
isFinal &&
|
||||
@@ -288,7 +297,8 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
deliveredText: deliveredStreamTextBeforeUpdate,
|
||||
finalText,
|
||||
}) &&
|
||||
deliveredStreamTextBeforeUpdate.length > firstChunk.trimEnd().length;
|
||||
deliveredStreamTextBeforeUpdate.length > activeChunk.trimEnd().length;
|
||||
|
||||
const finalizeDeliveredPrefix = async (
|
||||
deliveredStreamText: string,
|
||||
messageId: number,
|
||||
@@ -310,7 +320,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
}
|
||||
}
|
||||
}
|
||||
const suffix = finalText.slice(deliveredStreamText.length);
|
||||
const suffix = activeFullText.slice(deliveredStreamText.length);
|
||||
if (suffix.trim().length > 0) {
|
||||
for (const chunk of compactChunks(params.splitFinalTextForStream?.(suffix) ?? [])) {
|
||||
if (chunk.trim().length === 0) {
|
||||
@@ -327,17 +337,29 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
});
|
||||
};
|
||||
|
||||
const candidateTexts = [stream.lastDeliveredText?.(), lane.lastPartialText];
|
||||
if (isFinal && remainingChunks.length === 0 && isPotentialTruncatedFinal(activeFullText)) {
|
||||
const resolvedFullCandidate = await params.resolveFinalTextCandidate?.({
|
||||
finalText: text,
|
||||
laneName,
|
||||
});
|
||||
if (resolvedFullCandidate) {
|
||||
const resolvedChunks =
|
||||
resolvedFullCandidate.length > params.draftMaxChars
|
||||
? compactChunks(params.splitFinalTextForStream?.(resolvedFullCandidate) ?? [])
|
||||
: [resolvedFullCandidate];
|
||||
candidateTexts.push(resolvedChunks.slice(activeChunkIndex).join(""));
|
||||
}
|
||||
}
|
||||
|
||||
const retainedPreview =
|
||||
isFinal && remainingChunks.length === 0 && isPotentialTruncatedFinal(text)
|
||||
isFinal && remainingChunks.length === 0 && isPotentialTruncatedFinal(activeFullText)
|
||||
? selectLongerFinalText({
|
||||
finalText: text,
|
||||
candidateTexts: [
|
||||
await params.resolveFinalTextCandidate?.({ finalText: text, laneName }),
|
||||
stream.lastDeliveredText?.(),
|
||||
lane.lastPartialText,
|
||||
],
|
||||
finalText: activeFullText,
|
||||
candidateTexts,
|
||||
})
|
||||
: undefined;
|
||||
|
||||
if (retainedPreview && (!buttons || retainedPreview.length <= params.draftMaxChars)) {
|
||||
const previewText = retainedPreview;
|
||||
lane.lastPartialText = previewText;
|
||||
@@ -376,20 +398,28 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
}
|
||||
lane.finalized = true;
|
||||
params.markDelivered();
|
||||
return result("preview-finalized", { content: previewText, messageId, buttonsAttached });
|
||||
return result("preview-finalized", {
|
||||
content: previewText,
|
||||
promptContextContent: previewText,
|
||||
messageId,
|
||||
buttonsAttached,
|
||||
});
|
||||
}
|
||||
|
||||
if (!deliveredPrefixBeforeUpdate) {
|
||||
lane.lastPartialText = firstChunk;
|
||||
lane.lastPartialText = activeChunk;
|
||||
lane.hasStreamedMessage = true;
|
||||
lane.finalized = false;
|
||||
stream.update(firstChunk);
|
||||
stream.update(activeChunk);
|
||||
}
|
||||
if (isFinal) {
|
||||
await params.stopDraftLane(lane);
|
||||
} else {
|
||||
await params.flushDraftLane(lane);
|
||||
}
|
||||
const activeChunkIndexAfterStop = isFinal ? clampActiveChunkIndex() : activeChunkIndex;
|
||||
const activeChunkAfterStop = chunks[activeChunkIndexAfterStop] ?? activeChunk;
|
||||
const remainingChunksAfterStop = chunks.slice(activeChunkIndexAfterStop + 1);
|
||||
|
||||
const messageId = stream.messageId();
|
||||
if (typeof messageId !== "number") {
|
||||
@@ -402,14 +432,19 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
}
|
||||
|
||||
const deliveredStreamTextAfterStop = stream.lastDeliveredText?.();
|
||||
const activeChunkTextAfterStop = activeChunkAfterStop.trimEnd();
|
||||
const retainedActiveChunkAfterStop =
|
||||
activeChunkIndexAfterStop !== activeChunkIndex &&
|
||||
deliveredStreamTextAfterStop === activeChunk.trimEnd();
|
||||
if (
|
||||
isFinal &&
|
||||
deliveredStreamTextAfterStop !== undefined &&
|
||||
deliveredStreamTextAfterStop !== firstChunk.trimEnd()
|
||||
deliveredStreamTextAfterStop !== activeChunkTextAfterStop &&
|
||||
!retainedActiveChunkAfterStop
|
||||
) {
|
||||
if (
|
||||
isDeliveredPrefix({ deliveredText: deliveredStreamTextAfterStop, finalText }) &&
|
||||
deliveredStreamTextAfterStop.length > firstChunk.trimEnd().length
|
||||
deliveredStreamTextAfterStop.length > activeChunkTextAfterStop.length
|
||||
) {
|
||||
return await finalizeDeliveredPrefix(deliveredStreamTextAfterStop, messageId);
|
||||
}
|
||||
@@ -424,7 +459,12 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
let buttonsAttached = false;
|
||||
if (buttons) {
|
||||
try {
|
||||
await params.editStreamMessage({ laneName, messageId, text: firstChunk, buttons });
|
||||
await params.editStreamMessage({
|
||||
laneName,
|
||||
messageId,
|
||||
text: activeChunkAfterStop,
|
||||
buttons,
|
||||
});
|
||||
buttonsAttached = true;
|
||||
} catch (err) {
|
||||
params.log(`telegram: ${laneName} stream button edit failed: ${String(err)}`);
|
||||
@@ -433,7 +473,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
|
||||
if (isFinal) {
|
||||
lane.finalized = true;
|
||||
for (const chunk of remainingChunks) {
|
||||
for (const chunk of remainingChunksAfterStop) {
|
||||
if (chunk.trim().length === 0) {
|
||||
continue;
|
||||
}
|
||||
@@ -441,7 +481,7 @@ export function createLaneTextDeliverer(params: CreateLaneTextDelivererParams) {
|
||||
}
|
||||
return result("preview-finalized", {
|
||||
content: text,
|
||||
promptContextContent: firstChunk,
|
||||
promptContextContent: activeChunkAfterStop,
|
||||
messageId,
|
||||
buttonsAttached,
|
||||
});
|
||||
|
||||
@@ -31,12 +31,14 @@ function createHarness(params?: {
|
||||
lastPartialText: "",
|
||||
hasStreamedMessage: false,
|
||||
finalized: false,
|
||||
activeChunkIndex: 0,
|
||||
},
|
||||
reasoning: {
|
||||
stream: reasoning,
|
||||
lastPartialText: "",
|
||||
hasStreamedMessage: false,
|
||||
finalized: false,
|
||||
activeChunkIndex: 0,
|
||||
},
|
||||
};
|
||||
const sendPayload = vi.fn().mockResolvedValue(true);
|
||||
@@ -762,6 +764,87 @@ describe("createLaneTextDeliverer", () => {
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not resend chunks retained while stopping a long streamed final", async () => {
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
draftMaxChars: 5,
|
||||
splitFinalTextForStream: () => ["Hello", " world", " again"],
|
||||
});
|
||||
harness.lanes.answer.hasStreamedMessage = true;
|
||||
answer.stop.mockImplementation(async () => {
|
||||
harness.lanes.answer.activeChunkIndex = 1;
|
||||
});
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "Hello world again");
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.content).toBe("Hello world again");
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: " again" });
|
||||
expect(harness.markDelivered).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("compares retained delivered prefixes against the full final text", async () => {
|
||||
let deliveredText = "Hello";
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
draftMaxChars: 5,
|
||||
splitFinalTextForStream: (text) =>
|
||||
text === " again" ? [" again"] : ["Hello", " world", " again"],
|
||||
});
|
||||
answer.lastDeliveredText.mockImplementation(() => deliveredText);
|
||||
answer.stop.mockImplementation(async () => {
|
||||
harness.lanes.answer.activeChunkIndex = 1;
|
||||
deliveredText = "Hello world";
|
||||
});
|
||||
harness.lanes.answer.hasStreamedMessage = true;
|
||||
|
||||
const result = await deliverFinalAnswer(harness, "Hello world again");
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.promptContextContent).toBe("Hello world");
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({ text: " again" });
|
||||
});
|
||||
|
||||
it("edits buttons onto the chunk active after stopping a retained long final", async () => {
|
||||
const buttons = [[{ text: "OK", callback_data: "ok" }]];
|
||||
const answer = createTestDraftStream({ messageId: 999 });
|
||||
const harness = createHarness({
|
||||
answerStream: answer,
|
||||
draftMaxChars: 6,
|
||||
splitFinalTextForStream: () => ["Hello", " world", " again"],
|
||||
});
|
||||
harness.lanes.answer.hasStreamedMessage = true;
|
||||
answer.stop.mockImplementation(async () => {
|
||||
harness.lanes.answer.activeChunkIndex = 1;
|
||||
});
|
||||
|
||||
const result = await harness.deliverLaneText({
|
||||
laneName: "answer",
|
||||
text: "Hello world again",
|
||||
payload: { text: "Hello world again", channelData: { telegram: { buttons } } },
|
||||
infoKind: "final",
|
||||
buttons,
|
||||
});
|
||||
|
||||
const delivery = expectPreviewFinalized(result);
|
||||
expect(delivery.buttonsAttached).toBe(true);
|
||||
expect(harness.editStreamMessage).toHaveBeenCalledWith({
|
||||
laneName: "answer",
|
||||
messageId: 999,
|
||||
text: " world",
|
||||
buttons,
|
||||
});
|
||||
expect(harness.sendPayload).toHaveBeenCalledTimes(1);
|
||||
expect(harness.sendPayload).toHaveBeenCalledWith({
|
||||
text: " again",
|
||||
channelData: { telegram: { buttons } },
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps inline buttons on the current chunk of an already-streamed long final", async () => {
|
||||
const buttons = [[{ text: "OK", callback_data: "ok" }]];
|
||||
const fullAnswer = "Hello world again";
|
||||
|
||||
@@ -760,6 +760,45 @@ describe("telegram message cache", () => {
|
||||
expect(context.map((entry) => entry.node.body)).not.toContain(staleInstruction);
|
||||
});
|
||||
|
||||
it("uses the current reset command as the session boundary", async () => {
|
||||
const cache = createTelegramMessageCache();
|
||||
const chat = { id: 7, type: "group", title: "Ops" } as const;
|
||||
await cache.record({
|
||||
accountId: "default",
|
||||
chatId: 7,
|
||||
msg: {
|
||||
chat,
|
||||
message_id: 100,
|
||||
date: 1736380800,
|
||||
text: "stale context",
|
||||
from: { id: 100, is_bot: false, first_name: "Requester" },
|
||||
} as Message,
|
||||
});
|
||||
await cache.record({
|
||||
accountId: "default",
|
||||
chatId: 7,
|
||||
msg: {
|
||||
chat,
|
||||
message_id: 101,
|
||||
date: 1736380860,
|
||||
text: "/new",
|
||||
from: { id: 101, is_bot: false, first_name: "Requester" },
|
||||
} as Message,
|
||||
});
|
||||
|
||||
const context = await buildTelegramConversationContext({
|
||||
cache,
|
||||
accountId: "default",
|
||||
chatId: 7,
|
||||
messageId: "101",
|
||||
replyChainNodes: [],
|
||||
recentLimit: 10,
|
||||
replyTargetWindowSize: 1,
|
||||
});
|
||||
|
||||
expect(context).toEqual([]);
|
||||
});
|
||||
|
||||
it("does not select messages before the persisted session start when the reset command is absent", async () => {
|
||||
const cache = createTelegramMessageCache();
|
||||
const beforeSession = Date.parse("2026-05-10T12:40:00.000Z");
|
||||
|
||||
@@ -54,6 +54,13 @@ export type TelegramMessageCache = {
|
||||
before: number;
|
||||
after: number;
|
||||
}) => Promise<TelegramCachedMessageNode[]>;
|
||||
latestMatchingAtOrBefore: (params: {
|
||||
accountId: string;
|
||||
chatId: string | number;
|
||||
messageId?: string;
|
||||
threadId?: number;
|
||||
matches: (node: TelegramCachedMessageNode) => boolean;
|
||||
}) => Promise<TelegramCachedMessageNode | null>;
|
||||
};
|
||||
|
||||
type MessageWithExternalReply = Message & { external_reply?: Message };
|
||||
@@ -712,6 +719,40 @@ export function createTelegramMessageCache(params?: {
|
||||
targetIndex + Math.max(0, after) + 1,
|
||||
);
|
||||
},
|
||||
latestMatchingAtOrBefore: async ({ accountId, chatId, messageId, threadId, matches }) => {
|
||||
if (!messageId) {
|
||||
return null;
|
||||
}
|
||||
const targetId = parseSafeMessageId(messageId);
|
||||
if (targetId === undefined) {
|
||||
return null;
|
||||
}
|
||||
await hydrateMessageCacheBucket(bucket, maxMessages, scopeKey);
|
||||
const prefix = telegramMessageCacheKeyPrefix({ scopeKey, accountId, chatId });
|
||||
const normalizedThreadId = normalizeTelegramCacheThreadId(threadId);
|
||||
if (threadId != null && normalizedThreadId === undefined) {
|
||||
return null;
|
||||
}
|
||||
const normalizedThread =
|
||||
normalizedThreadId !== undefined ? String(normalizedThreadId) : undefined;
|
||||
let latest: TelegramCachedMessageNode | null = null;
|
||||
for (const [key, entry] of messages) {
|
||||
if (!key.startsWith(prefix)) {
|
||||
continue;
|
||||
}
|
||||
if (normalizedThread !== undefined && entry.threadId !== normalizedThread) {
|
||||
continue;
|
||||
}
|
||||
const entryId = parseSafeMessageId(entry.messageId);
|
||||
if (entryId === undefined || entryId > targetId || !matches(entry)) {
|
||||
continue;
|
||||
}
|
||||
if (!latest || compareCachedMessageNodes(entry, latest) > 0) {
|
||||
latest = entry;
|
||||
}
|
||||
}
|
||||
return latest;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -789,25 +830,15 @@ async function resolveSessionBoundaryNode(params: {
|
||||
if (!params.messageId) {
|
||||
return undefined;
|
||||
}
|
||||
const { messageId } = params;
|
||||
const candidates = (
|
||||
await params.cache.recentBefore({
|
||||
return (
|
||||
(await params.cache.latestMatchingAtOrBefore({
|
||||
accountId: params.accountId,
|
||||
chatId: params.chatId,
|
||||
messageId,
|
||||
messageId: params.messageId,
|
||||
...(params.threadId !== undefined ? { threadId: params.threadId } : {}),
|
||||
limit: Number.MAX_SAFE_INTEGER,
|
||||
})
|
||||
).filter(isSessionBoundaryCommandNode);
|
||||
const current = await params.cache.get({
|
||||
accountId: params.accountId,
|
||||
chatId: params.chatId,
|
||||
messageId,
|
||||
});
|
||||
if (current && isSessionBoundaryCommandNode(current)) {
|
||||
candidates.push(current);
|
||||
}
|
||||
return candidates.toSorted(compareCachedMessageNodes).at(-1);
|
||||
matches: isSessionBoundaryCommandNode,
|
||||
})) ?? undefined
|
||||
);
|
||||
}
|
||||
|
||||
export async function buildTelegramReplyChain(params: {
|
||||
|
||||
@@ -19,7 +19,9 @@ import type { TelegramIngressWorkerMessage } from "./telegram-ingress-worker.js"
|
||||
const runMock = vi.hoisted(() => vi.fn());
|
||||
const createTelegramBotMock = vi.hoisted(() => vi.fn());
|
||||
const isRecoverableTelegramNetworkErrorMock = vi.hoisted(() => vi.fn(() => true));
|
||||
const computeBackoffMock = vi.hoisted(() => vi.fn(() => 0));
|
||||
const computeBackoffMock = vi.hoisted(() =>
|
||||
vi.fn((_policy: { initialMs: number }, _attempt: number) => 0),
|
||||
);
|
||||
const sleepWithAbortMock = vi.hoisted(() => vi.fn(async () => undefined));
|
||||
const drainPendingDeliveriesMock = vi.hoisted(() => vi.fn(async (_opts: unknown) => undefined));
|
||||
|
||||
@@ -190,19 +192,37 @@ function installPollingStallWatchdogHarness(dateNowSequence: readonly number[] =
|
||||
});
|
||||
const realSetTimeout = globalThis.setTimeout;
|
||||
const realClearTimeout = globalThis.clearTimeout;
|
||||
const watchdogs: Array<() => void> = [];
|
||||
const watchdogWaiters: Array<{
|
||||
count: number;
|
||||
resolve: (fn: () => void) => void;
|
||||
reject: (err: Error) => void;
|
||||
timeout: ReturnType<typeof realSetTimeout>;
|
||||
}> = [];
|
||||
const setIntervalSpy = vi.spyOn(globalThis, "setInterval").mockImplementation((fn, delay) => {
|
||||
if (delay === POLLING_TEST_WATCHDOG_INTERVAL_MS) {
|
||||
watchdog = fn as () => void;
|
||||
watchdogs.push(watchdog);
|
||||
resolveWatchdog?.(watchdog);
|
||||
for (let index = watchdogWaiters.length - 1; index >= 0; index -= 1) {
|
||||
const waiter = watchdogWaiters[index];
|
||||
if (watchdogs.length < waiter.count) {
|
||||
continue;
|
||||
}
|
||||
realClearTimeout(waiter.timeout);
|
||||
watchdogWaiters.splice(index, 1);
|
||||
waiter.resolve(watchdogs[waiter.count - 1]);
|
||||
}
|
||||
}
|
||||
return 1 as unknown as ReturnType<typeof setInterval>;
|
||||
});
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, "clearInterval").mockImplementation(() => {});
|
||||
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation((fn) => {
|
||||
void Promise.resolve().then(() => (fn as () => void)());
|
||||
return 1 as unknown as ReturnType<typeof setTimeout>;
|
||||
const setTimeoutSpy = vi
|
||||
.spyOn(globalThis, "setTimeout")
|
||||
.mockImplementation((fn) => realSetTimeout(fn as () => void, 0));
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation((timeoutId) => {
|
||||
realClearTimeout(timeoutId);
|
||||
});
|
||||
const clearTimeoutSpy = vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => {});
|
||||
const dateNowSpy = vi.spyOn(Date, "now");
|
||||
for (const value of dateNowSequence) {
|
||||
dateNowSpy.mockImplementationOnce(() => value);
|
||||
@@ -230,6 +250,18 @@ function installPollingStallWatchdogHarness(dateNowSequence: readonly number[] =
|
||||
);
|
||||
});
|
||||
},
|
||||
async waitForWatchdogRegistration(count: number) {
|
||||
const registered = watchdogs[count - 1];
|
||||
if (registered) {
|
||||
return registered;
|
||||
}
|
||||
return await new Promise<() => void>((resolve, reject) => {
|
||||
const timeout = realSetTimeout(() => {
|
||||
reject(new Error(`Timed out waiting for polling watchdog registration ${count}`));
|
||||
}, 5_000);
|
||||
watchdogWaiters.push({ count, resolve, reject, timeout });
|
||||
});
|
||||
},
|
||||
setNow(now: number) {
|
||||
dateNowSpy.mockReset();
|
||||
dateNowSpy.mockImplementation(() => now);
|
||||
@@ -662,9 +694,31 @@ describe("TelegramPollingSession", () => {
|
||||
mockObjectArg(createTelegramBotMock, "createTelegramBot").minimumClientTimeoutSeconds,
|
||||
).toBe(45);
|
||||
expect(computeBackoffMock).toHaveBeenCalledTimes(1);
|
||||
expect(computeBackoffMock).toHaveBeenCalledWith(
|
||||
{
|
||||
initialMs: 30_000,
|
||||
maxMs: 600_000,
|
||||
factor: 2,
|
||||
jitter: 0.2,
|
||||
},
|
||||
1,
|
||||
);
|
||||
expect(sleepWithAbortMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("resets restart backoff after a healthy polling cycle", () => {
|
||||
const state = pollingSessionTesting.createTelegramRestartBackoffState();
|
||||
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true });
|
||||
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true });
|
||||
pollingSessionTesting.resetTelegramRestartBackoffState(state);
|
||||
pollingSessionTesting.resolveTelegramRestartDelayMs(state);
|
||||
|
||||
expect(computeBackoffMock.mock.calls.map((call) => call[1])).toEqual([1, 2, 1, 1]);
|
||||
expect(
|
||||
computeBackoffMock.mock.calls.map((call) => (call[0] as { initialMs: number }).initialMs),
|
||||
).toEqual([30_000, 30_000, 120_000, 30_000]);
|
||||
});
|
||||
|
||||
it("does not call getUpdates for offset confirmation (avoiding 409 conflicts)", async () => {
|
||||
const abort = new AbortController();
|
||||
const bot = makeBot();
|
||||
@@ -961,6 +1015,63 @@ describe("TelegramPollingSession", () => {
|
||||
await runPromise;
|
||||
});
|
||||
|
||||
it("resets restart backoff after isolated ingress reports poll success", async () => {
|
||||
const abort = new AbortController();
|
||||
const init = vi.fn(async () => undefined);
|
||||
const bot = {
|
||||
api: {
|
||||
deleteWebhook: vi.fn(async () => true),
|
||||
config: { use: vi.fn() },
|
||||
},
|
||||
init,
|
||||
handleUpdate: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
};
|
||||
createTelegramBotMock.mockReturnValue(bot);
|
||||
sleepWithAbortMock.mockImplementation(async () => {
|
||||
if (sleepWithAbortMock.mock.calls.length >= 2) {
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
let cycle = 0;
|
||||
const createWorker = vi.fn(() => {
|
||||
let onMessage: WorkerPollSuccessListener | undefined;
|
||||
cycle += 1;
|
||||
return {
|
||||
onMessage: vi.fn((handler) => {
|
||||
onMessage = handler;
|
||||
return () => undefined;
|
||||
}),
|
||||
stop: vi.fn(async () => undefined),
|
||||
task: vi.fn(async () => {
|
||||
if (cycle === 2) {
|
||||
onMessage?.({
|
||||
type: "poll-success",
|
||||
offset: null,
|
||||
finishedAt: Date.now(),
|
||||
count: 0,
|
||||
});
|
||||
}
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
const session = createPollingSession({
|
||||
abortSignal: abort.signal,
|
||||
isolatedIngress: {
|
||||
enabled: true,
|
||||
createWorker,
|
||||
drainIntervalMs: 10,
|
||||
},
|
||||
});
|
||||
|
||||
await session.runUntilAbort();
|
||||
|
||||
expect(createWorker).toHaveBeenCalledTimes(2);
|
||||
expect(computeBackoffMock.mock.calls.map((call) => call[1])).toEqual([1, 1]);
|
||||
});
|
||||
|
||||
it("restarts isolated ingress when worker liveness stalls", async () => {
|
||||
const abort = new AbortController();
|
||||
const log = vi.fn();
|
||||
@@ -1026,6 +1137,98 @@ describe("TelegramPollingSession", () => {
|
||||
|
||||
expectLogIncludes(log, "Polling stall detected");
|
||||
expectLogIncludes(log, "isolated polling ingress finished reason=polling stall detected");
|
||||
expectLogExcludes(log, "Isolated polling ingress stop timed out");
|
||||
} finally {
|
||||
watchdogHarness.restore();
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
it("applies stop-timeout cooldown to isolated ingress forced restarts", async () => {
|
||||
const abort = new AbortController();
|
||||
const log = vi.fn();
|
||||
const bot = {
|
||||
api: {
|
||||
deleteWebhook: vi.fn(async () => true),
|
||||
config: { use: vi.fn() },
|
||||
},
|
||||
init: vi.fn(async () => undefined),
|
||||
handleUpdate: vi.fn(async () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
};
|
||||
createTelegramBotMock.mockReturnValue(bot);
|
||||
computeBackoffMock.mockImplementation((policy: { initialMs: number }, attempt: number) => {
|
||||
if (policy.initialMs === 120_000) {
|
||||
return attempt * 100_000;
|
||||
}
|
||||
return attempt * 1_000;
|
||||
});
|
||||
|
||||
const finishStoppedWorkers: Array<() => void> = [];
|
||||
let workerCycle = 0;
|
||||
const createWorker = vi.fn(() => {
|
||||
workerCycle += 1;
|
||||
if (workerCycle <= 2) {
|
||||
let finishTask: (() => void) | undefined;
|
||||
const task = new Promise<void>((resolve) => {
|
||||
finishTask = resolve;
|
||||
});
|
||||
let finishStop: (() => void) | undefined;
|
||||
const stop = new Promise<void>((resolve) => {
|
||||
finishStop = resolve;
|
||||
});
|
||||
finishStoppedWorkers.push(() => {
|
||||
finishStop?.();
|
||||
finishTask?.();
|
||||
});
|
||||
return {
|
||||
onMessage: vi.fn(() => () => undefined),
|
||||
stop: vi.fn(() => stop),
|
||||
task: vi.fn(async () => {
|
||||
await task;
|
||||
}),
|
||||
};
|
||||
}
|
||||
return {
|
||||
onMessage: vi.fn(() => () => undefined),
|
||||
stop: vi.fn(async () => undefined),
|
||||
task: vi.fn(async () => {
|
||||
abort.abort();
|
||||
}),
|
||||
};
|
||||
});
|
||||
const watchdogHarness = installPollingStallWatchdogHarness([0]);
|
||||
const session = createPollingSession({
|
||||
abortSignal: abort.signal,
|
||||
log,
|
||||
stallThresholdMs: 30_000,
|
||||
isolatedIngress: {
|
||||
enabled: true,
|
||||
createWorker,
|
||||
drainIntervalMs: 500,
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
const runPromise = session.runUntilAbort();
|
||||
const firstWatchdog = await watchdogHarness.waitForWatchdog();
|
||||
watchdogHarness.setNow(31_000);
|
||||
firstWatchdog?.();
|
||||
await vi.waitFor(() => expectLogIncludes(log, "Isolated polling ingress stop timed out"));
|
||||
finishStoppedWorkers.shift()?.();
|
||||
await vi.waitFor(() => expect(createWorker).toHaveBeenCalledTimes(2));
|
||||
|
||||
const secondWatchdog = await watchdogHarness.waitForWatchdogRegistration(2);
|
||||
watchdogHarness.setNow(62_000);
|
||||
secondWatchdog?.();
|
||||
await vi.waitFor(() => expectLogIncludes(log, "Stop timeout burst=2; applying cooldown."));
|
||||
finishStoppedWorkers.shift()?.();
|
||||
await runPromise;
|
||||
|
||||
const stopCooldownCalls = computeBackoffMock.mock.calls.filter(
|
||||
([policy]) => (policy as { initialMs: number }).initialMs === 120_000,
|
||||
);
|
||||
expect(stopCooldownCalls.map((call) => call[1])).toEqual([1]);
|
||||
} finally {
|
||||
watchdogHarness.restore();
|
||||
abort.abort();
|
||||
@@ -2762,6 +2965,7 @@ describe("TelegramPollingSession", () => {
|
||||
it("forces a restart when polling stalls without getUpdates activity", async () => {
|
||||
const abort = new AbortController();
|
||||
const botStop = vi.fn(async () => undefined);
|
||||
const secondBotStop = vi.fn(async () => undefined);
|
||||
const firstRunnerStop = vi.fn(async () => undefined);
|
||||
const secondRunnerStop = vi.fn(async () => undefined);
|
||||
const bot = {
|
||||
@@ -2772,7 +2976,10 @@ describe("TelegramPollingSession", () => {
|
||||
},
|
||||
stop: botStop,
|
||||
};
|
||||
createTelegramBotMock.mockReturnValue(bot);
|
||||
createTelegramBotMock.mockReturnValueOnce(bot).mockReturnValueOnce({
|
||||
...bot,
|
||||
stop: secondBotStop,
|
||||
});
|
||||
|
||||
let firstTaskResolve: (() => void) | undefined;
|
||||
const firstTask = new Promise<void>((resolve) => {
|
||||
@@ -2826,14 +3033,46 @@ describe("TelegramPollingSession", () => {
|
||||
|
||||
expect(runMock).toHaveBeenCalledTimes(2);
|
||||
expect(firstRunnerStop).toHaveBeenCalledTimes(1);
|
||||
expect(botStop).toHaveBeenCalled();
|
||||
expect(botStop).toHaveBeenCalledTimes(1);
|
||||
expectLogIncludes(log, "Polling stall detected");
|
||||
expectLogIncludes(log, "polling stall detected");
|
||||
expectLogExcludes(log, "Polling runner stop timed out");
|
||||
} finally {
|
||||
watchdogHarness.restore();
|
||||
}
|
||||
});
|
||||
|
||||
it("cools down repeated stop-timeout restart bursts", () => {
|
||||
computeBackoffMock.mockImplementation((policy: { initialMs: number }, attempt: number) => {
|
||||
if (policy.initialMs === 120_000) {
|
||||
return attempt * 100_000;
|
||||
}
|
||||
return attempt * 1_000;
|
||||
});
|
||||
|
||||
const state = pollingSessionTesting.createTelegramRestartBackoffState();
|
||||
expect(
|
||||
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true }),
|
||||
).toEqual({ delayMs: 1_000, stopTimeoutSuffix: "" });
|
||||
expect(
|
||||
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true }),
|
||||
).toEqual({
|
||||
delayMs: 100_000,
|
||||
stopTimeoutSuffix: " Stop timeout burst=2; applying cooldown.",
|
||||
});
|
||||
expect(
|
||||
pollingSessionTesting.resolveTelegramRestartDelayMs(state, { stopTimedOut: true }),
|
||||
).toEqual({
|
||||
delayMs: 200_000,
|
||||
stopTimeoutSuffix: " Stop timeout burst=3; applying cooldown.",
|
||||
});
|
||||
|
||||
const stopCooldownCalls = computeBackoffMock.mock.calls.filter(
|
||||
([policy]) => (policy as { initialMs: number }).initialMs === 120_000,
|
||||
);
|
||||
expect(stopCooldownCalls.map((call) => call[1])).toEqual([1, 2]);
|
||||
});
|
||||
|
||||
it("forces a restart when the runner task is pending but reports not running", async () => {
|
||||
const abort = new AbortController();
|
||||
const firstRunnerStop = vi.fn(async () => undefined);
|
||||
|
||||
@@ -50,12 +50,65 @@ import {
|
||||
} from "./telegram-reply-fence.js";
|
||||
|
||||
const TELEGRAM_POLL_RESTART_POLICY = {
|
||||
initialMs: 2000,
|
||||
maxMs: 30_000,
|
||||
factor: 1.8,
|
||||
jitter: 0.25,
|
||||
initialMs: 30_000,
|
||||
maxMs: 600_000,
|
||||
factor: 2,
|
||||
jitter: 0.2,
|
||||
};
|
||||
|
||||
const TELEGRAM_POLL_STOP_TIMEOUT_COOLDOWN_POLICY = {
|
||||
initialMs: 120_000,
|
||||
maxMs: 600_000,
|
||||
factor: 2,
|
||||
jitter: 0.2,
|
||||
};
|
||||
const TELEGRAM_POLL_STOP_TIMEOUT_BURST_LIMIT = 2;
|
||||
|
||||
type TelegramRestartBackoffState = {
|
||||
restartAttempts: number;
|
||||
stopTimeoutBurst: number;
|
||||
stopTimeoutCooldownAttempts: number;
|
||||
};
|
||||
|
||||
function createTelegramRestartBackoffState(): TelegramRestartBackoffState {
|
||||
return {
|
||||
restartAttempts: 0,
|
||||
stopTimeoutBurst: 0,
|
||||
stopTimeoutCooldownAttempts: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function resetTelegramRestartBackoffState(state: TelegramRestartBackoffState): void {
|
||||
state.restartAttempts = 0;
|
||||
state.stopTimeoutBurst = 0;
|
||||
state.stopTimeoutCooldownAttempts = 0;
|
||||
}
|
||||
|
||||
function resolveTelegramRestartDelayMs(
|
||||
state: TelegramRestartBackoffState,
|
||||
opts: { stopTimedOut?: boolean } = {},
|
||||
): { delayMs: number; stopTimeoutSuffix: string } {
|
||||
state.restartAttempts += 1;
|
||||
let delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, state.restartAttempts);
|
||||
let stopTimeoutSuffix = "";
|
||||
if (opts.stopTimedOut) {
|
||||
state.stopTimeoutBurst += 1;
|
||||
if (state.stopTimeoutBurst >= TELEGRAM_POLL_STOP_TIMEOUT_BURST_LIMIT) {
|
||||
state.stopTimeoutCooldownAttempts += 1;
|
||||
const cooldownMs = computeBackoff(
|
||||
TELEGRAM_POLL_STOP_TIMEOUT_COOLDOWN_POLICY,
|
||||
state.stopTimeoutCooldownAttempts,
|
||||
);
|
||||
delayMs = Math.max(delayMs, cooldownMs);
|
||||
stopTimeoutSuffix = ` Stop timeout burst=${state.stopTimeoutBurst}; applying cooldown.`;
|
||||
}
|
||||
} else {
|
||||
state.stopTimeoutBurst = 0;
|
||||
state.stopTimeoutCooldownAttempts = 0;
|
||||
}
|
||||
return { delayMs, stopTimeoutSuffix };
|
||||
}
|
||||
|
||||
const DEFAULT_POLL_STALL_THRESHOLD_MS = 120_000;
|
||||
const MIN_POLL_STALL_THRESHOLD_MS = 30_000;
|
||||
const MAX_POLL_STALL_THRESHOLD_MS = 600_000;
|
||||
@@ -239,7 +292,7 @@ function isSpooledUpdateHandlerKeyForSpool(handlerKey: string, spoolDir: string)
|
||||
}
|
||||
|
||||
export class TelegramPollingSession {
|
||||
#restartAttempts = 0;
|
||||
#restartBackoffState = createTelegramRestartBackoffState();
|
||||
#webhookCleared = false;
|
||||
#forceRestarted = false;
|
||||
#activeRunner: ReturnType<typeof run> | undefined;
|
||||
@@ -321,11 +374,20 @@ export class TelegramPollingSession {
|
||||
}
|
||||
}
|
||||
|
||||
async #waitBeforeRestart(buildLine: (delay: string) => string): Promise<boolean> {
|
||||
this.#restartAttempts += 1;
|
||||
const delayMs = computeBackoff(TELEGRAM_POLL_RESTART_POLICY, this.#restartAttempts);
|
||||
#noteHealthyPollingCycle() {
|
||||
resetTelegramRestartBackoffState(this.#restartBackoffState);
|
||||
}
|
||||
|
||||
async #waitBeforeRestart(
|
||||
buildLine: (delay: string) => string,
|
||||
opts: { stopTimedOut?: boolean } = {},
|
||||
): Promise<boolean> {
|
||||
const { delayMs, stopTimeoutSuffix } = resolveTelegramRestartDelayMs(
|
||||
this.#restartBackoffState,
|
||||
opts,
|
||||
);
|
||||
const delay = formatDurationPrecise(delayMs);
|
||||
this.opts.log(buildLine(delay));
|
||||
this.opts.log(`${buildLine(delay)}${stopTimeoutSuffix}`);
|
||||
try {
|
||||
await sleepWithAbort(delayMs, this.opts.abortSignal);
|
||||
} catch (sleepErr) {
|
||||
@@ -741,9 +803,7 @@ export class TelegramPollingSession {
|
||||
const stopWorker = () => {
|
||||
stopWorkerPromise ??= Promise.resolve(worker.stop())
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
// Worker may already be stopped by restart/abort paths.
|
||||
});
|
||||
.catch(() => undefined);
|
||||
return stopWorkerPromise;
|
||||
};
|
||||
this.opts.log(`[telegram][diag] isolated polling ingress started spool=${spoolDir}`);
|
||||
@@ -761,6 +821,7 @@ export class TelegramPollingSession {
|
||||
let consecutiveDrainFailures = 0;
|
||||
let restartRequested = false;
|
||||
let stalledRestart = false;
|
||||
let stopTimedOut = false;
|
||||
let forceCycleTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
let forceCycleResolve: (() => void) | undefined;
|
||||
const forceCyclePromise = new Promise<void>((resolve) => {
|
||||
@@ -796,6 +857,7 @@ export class TelegramPollingSession {
|
||||
if (message.type === "poll-success") {
|
||||
liveness.noteGetUpdatesSuccessCount(message.count, message.finishedAt);
|
||||
liveness.noteGetUpdatesFinished();
|
||||
this.#noteHealthyPollingCycle();
|
||||
if (!restartRequested && stalledBacklogKeys.size === 0) {
|
||||
this.#status.notePollSuccess(message.finishedAt);
|
||||
}
|
||||
@@ -840,9 +902,33 @@ export class TelegramPollingSession {
|
||||
const stopBot = () => {
|
||||
return Promise.resolve(bot.stop())
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
// Bot may already be stopped by shutdown paths.
|
||||
});
|
||||
.catch(() => undefined);
|
||||
};
|
||||
const clearForceCycleTimer = () => {
|
||||
if (!forceCycleTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(forceCycleTimer);
|
||||
forceCycleTimer = undefined;
|
||||
};
|
||||
const requestStopForRestart = () => {
|
||||
if (restartRequested) {
|
||||
return;
|
||||
}
|
||||
restartRequested = true;
|
||||
void stopWorker();
|
||||
if (!forceCycleTimer) {
|
||||
forceCycleTimer = setTimeout(() => {
|
||||
if (this.opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
this.opts.log(
|
||||
`[telegram] Isolated polling ingress stop timed out after ${formatDurationPrecise(POLL_STOP_GRACE_MS)}; forcing restart cycle.`,
|
||||
);
|
||||
stopTimedOut = true;
|
||||
forceCycleResolve?.();
|
||||
}, POLL_STOP_GRACE_MS);
|
||||
}
|
||||
};
|
||||
const drainOnce = async () => {
|
||||
if (restartRequested || drainActive || this.opts.abortSignal?.aborted) {
|
||||
@@ -872,8 +958,7 @@ export class TelegramPollingSession {
|
||||
}
|
||||
const timedOutRecovery = await this.#recoverTimedOutSpooledHandler(drain.blockedByLane);
|
||||
if (timedOutRecovery?.restart) {
|
||||
restartRequested = true;
|
||||
void stopWorker();
|
||||
requestStopForRestart();
|
||||
} else if (timedOutRecovery) {
|
||||
stalledBacklogKeys.add(timedOutRecovery.handlerKey);
|
||||
}
|
||||
@@ -903,26 +988,15 @@ export class TelegramPollingSession {
|
||||
}
|
||||
this.#transportState.markDirty();
|
||||
stalledRestart = true;
|
||||
restartRequested = true;
|
||||
this.opts.log(`[telegram] ${stall.message}`);
|
||||
this.#status.notePollingError(stall.message);
|
||||
void stopWorker();
|
||||
if (!forceCycleTimer) {
|
||||
forceCycleTimer = setTimeout(() => {
|
||||
if (this.opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
this.opts.log(
|
||||
`[telegram] Isolated polling ingress stop timed out after ${formatDurationPrecise(POLL_STOP_GRACE_MS)}; forcing restart cycle.`,
|
||||
);
|
||||
forceCycleResolve?.();
|
||||
}, POLL_STOP_GRACE_MS);
|
||||
}
|
||||
requestStopForRestart();
|
||||
}, POLL_WATCHDOG_INTERVAL_MS);
|
||||
watchdog.unref?.();
|
||||
try {
|
||||
try {
|
||||
await Promise.race([worker.task(), forceCyclePromise]);
|
||||
clearForceCycleTimer();
|
||||
} catch (err) {
|
||||
if (this.opts.abortSignal?.aborted) {
|
||||
return "exit";
|
||||
@@ -937,6 +1011,7 @@ export class TelegramPollingSession {
|
||||
const message = formatErrorMessage(err);
|
||||
this.opts.log(`[telegram][diag] isolated polling ingress failed: ${message}`);
|
||||
this.#status.notePollingError(message);
|
||||
clearForceCycleTimer();
|
||||
const shouldRestart = await this.#waitBeforeRestart(
|
||||
(delay) => `Telegram isolated polling ingress failed; restarting in ${delay}.`,
|
||||
);
|
||||
@@ -951,7 +1026,11 @@ export class TelegramPollingSession {
|
||||
`[telegram][diag] isolated polling ingress finished reason=polling stall detected ${liveness.formatDiagnosticFields("error")}`,
|
||||
);
|
||||
}
|
||||
return "continue";
|
||||
const shouldRestart = await this.#waitBeforeRestart(
|
||||
(delay) => `Telegram isolated polling ingress restart requested; restarting in ${delay}.`,
|
||||
{ stopTimedOut },
|
||||
);
|
||||
return shouldRestart ? "continue" : "exit";
|
||||
}
|
||||
const errorText = pollState.error ? ` error=${pollState.error}` : "";
|
||||
this.opts.log(
|
||||
@@ -964,9 +1043,7 @@ export class TelegramPollingSession {
|
||||
} finally {
|
||||
clearInterval(watchdog);
|
||||
clearInterval(drainTimer);
|
||||
if (forceCycleTimer) {
|
||||
clearTimeout(forceCycleTimer);
|
||||
}
|
||||
clearForceCycleTimer();
|
||||
unsubscribe();
|
||||
this.opts.abortSignal?.removeEventListener("abort", stopOnAbort);
|
||||
await stopWorker();
|
||||
@@ -981,6 +1058,7 @@ export class TelegramPollingSession {
|
||||
async #runPollingCycle(bot: TelegramBot): Promise<"continue" | "exit"> {
|
||||
const liveness = new TelegramPollingLivenessTracker({
|
||||
onPollSuccess: (finishedAt) => {
|
||||
this.#noteHealthyPollingCycle();
|
||||
this.#status.notePollSuccess(finishedAt);
|
||||
this.#drainPendingDeliveriesAfterReconnect();
|
||||
},
|
||||
@@ -1021,21 +1099,26 @@ export class TelegramPollingSession {
|
||||
const forceCyclePromise = new Promise<void>((resolve) => {
|
||||
forceCycleResolve = resolve;
|
||||
});
|
||||
const clearForceCycleTimer = () => {
|
||||
if (!forceCycleTimer) {
|
||||
return;
|
||||
}
|
||||
clearTimeout(forceCycleTimer);
|
||||
forceCycleTimer = undefined;
|
||||
};
|
||||
const stopRunner = () => {
|
||||
fetchAbortController?.abort();
|
||||
stopPromise ??= Promise.resolve(runner.stop())
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
// Runner may already be stopped by abort/retry paths.
|
||||
});
|
||||
.catch(() => undefined);
|
||||
return stopPromise;
|
||||
};
|
||||
let stopBotPromise: Promise<void> | undefined;
|
||||
const stopBot = () => {
|
||||
return Promise.resolve(bot.stop())
|
||||
stopBotPromise ??= Promise.resolve(bot.stop())
|
||||
.then(() => undefined)
|
||||
.catch(() => {
|
||||
// Bot may already be stopped by runner stop/abort paths.
|
||||
});
|
||||
.catch(() => undefined);
|
||||
return stopBotPromise;
|
||||
};
|
||||
const stopOnAbort = () => {
|
||||
if (this.opts.abortSignal?.aborted) {
|
||||
@@ -1043,8 +1126,31 @@ export class TelegramPollingSession {
|
||||
}
|
||||
};
|
||||
|
||||
let restartRequested = false;
|
||||
let stopTimedOut = false;
|
||||
const requestStopForRestart = () => {
|
||||
if (restartRequested) {
|
||||
return;
|
||||
}
|
||||
restartRequested = true;
|
||||
void stopRunner();
|
||||
void stopBot();
|
||||
if (!forceCycleTimer) {
|
||||
forceCycleTimer = setTimeout(() => {
|
||||
if (this.opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
this.opts.log(
|
||||
`[telegram] Polling runner stop timed out after ${formatDurationPrecise(POLL_STOP_GRACE_MS)}; forcing restart cycle.`,
|
||||
);
|
||||
stopTimedOut = true;
|
||||
forceCycleResolve?.();
|
||||
}, POLL_STOP_GRACE_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const watchdog = setInterval(() => {
|
||||
if (this.opts.abortSignal?.aborted) {
|
||||
if (this.opts.abortSignal?.aborted || restartRequested) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1055,25 +1161,14 @@ export class TelegramPollingSession {
|
||||
this.#transportState.markDirty();
|
||||
stalledRestart = true;
|
||||
this.opts.log(`[telegram] ${stall.message}`);
|
||||
void stopRunner();
|
||||
void stopBot();
|
||||
if (!forceCycleTimer) {
|
||||
forceCycleTimer = setTimeout(() => {
|
||||
if (this.opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
this.opts.log(
|
||||
`[telegram] Polling runner stop timed out after ${formatDurationPrecise(POLL_STOP_GRACE_MS)}; forcing restart cycle.`,
|
||||
);
|
||||
forceCycleResolve?.();
|
||||
}, POLL_STOP_GRACE_MS);
|
||||
}
|
||||
requestStopForRestart();
|
||||
}
|
||||
}, POLL_WATCHDOG_INTERVAL_MS);
|
||||
|
||||
this.opts.abortSignal?.addEventListener("abort", stopOnAbort, { once: true });
|
||||
try {
|
||||
await Promise.race([runner.task(), forceCyclePromise]);
|
||||
clearForceCycleTimer();
|
||||
if (this.opts.abortSignal?.aborted) {
|
||||
return "exit";
|
||||
}
|
||||
@@ -1088,6 +1183,7 @@ export class TelegramPollingSession {
|
||||
);
|
||||
const shouldRestart = await this.#waitBeforeRestart(
|
||||
(delay) => `Telegram polling runner stopped (${reason}); restarting in ${delay}.`,
|
||||
{ stopTimedOut },
|
||||
);
|
||||
return shouldRestart ? "continue" : "exit";
|
||||
} catch (err) {
|
||||
@@ -1119,15 +1215,14 @@ export class TelegramPollingSession {
|
||||
this.opts.log(
|
||||
`[telegram][diag] polling cycle error reason=${reason} ${liveness.formatDiagnosticFields("lastGetUpdatesError")} err=${errMsg}${conflictHint}`,
|
||||
);
|
||||
clearForceCycleTimer();
|
||||
const shouldRestart = await this.#waitBeforeRestart(
|
||||
(delay) => `Telegram ${reason}: ${errMsg};${conflictHint} retrying in ${delay}.`,
|
||||
);
|
||||
return shouldRestart ? "continue" : "exit";
|
||||
} finally {
|
||||
clearInterval(watchdog);
|
||||
if (forceCycleTimer) {
|
||||
clearTimeout(forceCycleTimer);
|
||||
}
|
||||
clearForceCycleTimer();
|
||||
this.opts.abortSignal?.removeEventListener("abort", abortFetch);
|
||||
this.opts.abortSignal?.removeEventListener("abort", stopOnAbort);
|
||||
await waitForGracefulStop(stopRunner);
|
||||
@@ -1166,6 +1261,9 @@ export const testing = {
|
||||
resetActiveSpooledUpdateHandlersForTests: (): void => {
|
||||
activeSpooledUpdateHandlersByLane.clear();
|
||||
},
|
||||
createTelegramRestartBackoffState,
|
||||
resetTelegramRestartBackoffState,
|
||||
resolveTelegramRestartDelayMs,
|
||||
resolveSpooledUpdateHandlerAbortGraceMs: (valueMs: unknown): number =>
|
||||
resolvePositiveTimerTimeoutMs(valueMs, TELEGRAM_SPOOLED_HANDLER_ABORT_GRACE_MS),
|
||||
};
|
||||
|
||||
@@ -1080,6 +1080,32 @@ describe("sendMessageTelegram", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves internal target writeback when gateway scopes are absent", async () => {
|
||||
const sendMessage = vi.fn().mockResolvedValue({
|
||||
message_id: 1,
|
||||
chat: { id: "-100123" },
|
||||
});
|
||||
const getChat = vi.fn().mockResolvedValue({ id: -100123 });
|
||||
const api = { sendMessage, getChat } as unknown as {
|
||||
sendMessage: typeof sendMessage;
|
||||
getChat: typeof getChat;
|
||||
};
|
||||
|
||||
await sendMessageTelegram("https://t.me/mychannel", "hi", {
|
||||
cfg: TELEGRAM_TEST_CFG,
|
||||
token: "tok",
|
||||
api,
|
||||
});
|
||||
|
||||
expect(getChat).toHaveBeenCalledWith("@mychannel");
|
||||
expectPersistedTarget({
|
||||
rawTarget: "https://t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: undefined,
|
||||
trustedInternalWriteback: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails clearly when a legacy target cannot be resolved", async () => {
|
||||
const getChat = vi.fn().mockRejectedValue(new Error("400: Bad Request: chat not found"));
|
||||
const api = { getChat } as unknown as {
|
||||
|
||||
@@ -399,6 +399,7 @@ async function resolveAndPersistChatId(params: {
|
||||
resolvedChatId: chatId,
|
||||
verbose: params.verbose,
|
||||
gatewayClientScopes: params.gatewayClientScopes,
|
||||
...(params.gatewayClientScopes === undefined ? { trustedInternalWriteback: true } : {}),
|
||||
});
|
||||
return chatId;
|
||||
}
|
||||
|
||||
@@ -95,6 +95,7 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawTarget: "-100123",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
|
||||
@@ -118,6 +119,23 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
|
||||
expect(saveCronStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not let internal writeback override non-admin gateway scopes", async () => {
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {
|
||||
cron: { store: "/tmp/cron/jobs.json" },
|
||||
} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: ["operator.write"],
|
||||
trustedInternalWriteback: true,
|
||||
});
|
||||
|
||||
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(loadCronStore).not.toHaveBeenCalled();
|
||||
expect(saveCronStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips config and cron writeback for gateway callers with an empty scope set", async () => {
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {
|
||||
@@ -133,6 +151,53 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
|
||||
expect(loadCronStore).not.toHaveBeenCalled();
|
||||
expect(saveCronStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips config and cron writeback when gateway scopes are missing", async () => {
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {
|
||||
cron: { store: "/tmp/cron/jobs.json" },
|
||||
} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: undefined,
|
||||
});
|
||||
|
||||
expect(readConfigFileSnapshotForWrite).not.toHaveBeenCalled();
|
||||
expect(writeConfigFile).not.toHaveBeenCalled();
|
||||
expect(loadCronStore).not.toHaveBeenCalled();
|
||||
expect(saveCronStore).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("writes back for gateway callers with operator.admin", async () => {
|
||||
readConfigFileSnapshotForWrite.mockResolvedValue({
|
||||
snapshot: {
|
||||
config: {
|
||||
channels: {
|
||||
telegram: {
|
||||
defaultTo: "t.me/mychannel",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
writeOptions: {},
|
||||
});
|
||||
loadCronStore.mockResolvedValue({
|
||||
version: 1,
|
||||
jobs: [{ id: "a", delivery: { channel: "telegram", to: "t.me/mychannel" } }],
|
||||
});
|
||||
|
||||
await maybePersistResolvedTelegramTarget({
|
||||
cfg: {
|
||||
cron: { store: "/tmp/cron/jobs.json" },
|
||||
} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: ["operator.admin"],
|
||||
});
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
expect(saveCronStore).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
}
|
||||
|
||||
it("writes back matching config and cron targets", async () => {
|
||||
@@ -167,6 +232,8 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
|
||||
} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: undefined,
|
||||
trustedInternalWriteback: true,
|
||||
});
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
@@ -202,6 +269,8 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawTarget: "t.me/mychannel:topic:9",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: undefined,
|
||||
trustedInternalWriteback: true,
|
||||
});
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
@@ -232,6 +301,8 @@ export function installMaybePersistResolvedTelegramTargetTests(params?: {
|
||||
cfg: {} as OpenClawConfig,
|
||||
rawTarget: "@MyChannel",
|
||||
resolvedChatId: "-100123",
|
||||
gatewayClientScopes: undefined,
|
||||
trustedInternalWriteback: true,
|
||||
});
|
||||
|
||||
expect(writeConfigFile).toHaveBeenCalledTimes(1);
|
||||
|
||||
@@ -147,6 +147,7 @@ export async function maybePersistResolvedTelegramTarget(params: {
|
||||
resolvedChatId: string;
|
||||
verbose?: boolean;
|
||||
gatewayClientScopes?: readonly string[];
|
||||
trustedInternalWriteback?: boolean;
|
||||
}): Promise<void> {
|
||||
const raw = params.rawTarget.trim();
|
||||
if (!raw) {
|
||||
@@ -160,10 +161,10 @@ export async function maybePersistResolvedTelegramTarget(params: {
|
||||
return;
|
||||
}
|
||||
const { matchKey, resolvedTarget } = rewrite;
|
||||
if (
|
||||
Array.isArray(params.gatewayClientScopes) &&
|
||||
!params.gatewayClientScopes.includes(TELEGRAM_ADMIN_SCOPE)
|
||||
) {
|
||||
const hasGatewayAdminScope = params.gatewayClientScopes?.includes(TELEGRAM_ADMIN_SCOPE) === true;
|
||||
const trustedInternalWriteback =
|
||||
params.gatewayClientScopes === undefined && params.trustedInternalWriteback === true;
|
||||
if (!hasGatewayAdminScope && !trustedInternalWriteback) {
|
||||
writebackLogger.warn(
|
||||
`skipping Telegram target writeback for ${raw} because gateway caller is missing ${TELEGRAM_ADMIN_SCOPE}`,
|
||||
);
|
||||
|
||||
@@ -166,7 +166,7 @@ describe("web auto-reply last-route", () => {
|
||||
SenderE164: "+1000",
|
||||
SenderId: "+1000",
|
||||
RawBody: "hello",
|
||||
Body: expect.stringMatching(/^\[WhatsApp \+1000 .+\] \+1000: hello$/),
|
||||
Body: expect.stringMatching(/^\[WhatsApp \+1000 .+\] \+1000: hello$/u),
|
||||
BodyForAgent: "hello",
|
||||
CommandBody: "hello",
|
||||
Timestamp: now,
|
||||
|
||||
@@ -1482,8 +1482,8 @@
|
||||
"crabbox:stop": "node scripts/crabbox-wrapper.mjs stop",
|
||||
"crabbox:warmup": "node scripts/crabbox-wrapper.mjs warmup",
|
||||
"deadcode:ci": "pnpm deadcode:report:ci:knip && pnpm deadcode:report:ci:ts-unused",
|
||||
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx knip@6.8.0 --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
|
||||
"deadcode:knip": "pnpm dlx knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",
|
||||
"deadcode:dependencies": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --dependencies --no-config-hints",
|
||||
"deadcode:knip": "pnpm --config.minimum-release-age=0 dlx --package knip@6.8.0 knip --config config/knip.config.ts --production --no-progress --reporter compact --files --dependencies",
|
||||
"deadcode:report": "pnpm deadcode:knip; pnpm deadcode:ts-prune; pnpm deadcode:ts-unused",
|
||||
"deadcode:report:ci:knip": "mkdir -p .artifacts/deadcode && pnpm deadcode:knip > .artifacts/deadcode/knip.txt 2>&1 || true",
|
||||
"deadcode:report:ci:ts-prune": "mkdir -p .artifacts/deadcode && pnpm deadcode:ts-prune > .artifacts/deadcode/ts-prune.txt 2>&1 || true",
|
||||
@@ -1591,7 +1591,6 @@
|
||||
"mac:open": "open dist/OpenClaw.app",
|
||||
"mac:package": "bash scripts/package-mac-app.sh",
|
||||
"mac:restart": "bash scripts/restart-mac.sh",
|
||||
"moltbot:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
|
||||
"openclaw": "node scripts/run-node.mjs",
|
||||
"openclaw:rpc": "node scripts/run-node.mjs agent --mode rpc --json",
|
||||
"perf:issue-78851": "node --import tsx scripts/perf/issue-78851-model-resolution.ts",
|
||||
|
||||
@@ -502,6 +502,7 @@ type PendingStop = {
|
||||
ws: WebSocket;
|
||||
promise: Promise<void>;
|
||||
resolve: () => void;
|
||||
terminateTimer?: NodeJS.Timeout;
|
||||
};
|
||||
|
||||
export class GatewayClient {
|
||||
@@ -783,8 +784,7 @@ export class GatewayClient {
|
||||
const ws = this.ws;
|
||||
this.ws = null;
|
||||
if (ws) {
|
||||
const stopPromise = this.createPendingStop(ws);
|
||||
ws.close();
|
||||
const pendingStop = this.createPendingStop(ws);
|
||||
const forceTerminateTimer = setTimeout(() => {
|
||||
try {
|
||||
ws.terminate();
|
||||
@@ -792,30 +792,35 @@ export class GatewayClient {
|
||||
this.resolvePendingStop(ws);
|
||||
}, FORCE_STOP_TERMINATE_GRACE_MS);
|
||||
forceTerminateTimer.unref?.();
|
||||
pendingStop.terminateTimer = forceTerminateTimer;
|
||||
ws.close();
|
||||
this.flushPendingErrors(new Error("gateway client stopped"));
|
||||
return stopPromise;
|
||||
return pendingStop.promise;
|
||||
}
|
||||
this.flushPendingErrors(new Error("gateway client stopped"));
|
||||
return null;
|
||||
}
|
||||
|
||||
private createPendingStop(ws: WebSocket): Promise<void> {
|
||||
private createPendingStop(ws: WebSocket): PendingStop {
|
||||
if (this.pendingStop?.ws === ws) {
|
||||
return this.pendingStop.promise;
|
||||
return this.pendingStop;
|
||||
}
|
||||
let resolve!: () => void;
|
||||
const promise = new Promise<void>((res) => {
|
||||
resolve = res;
|
||||
});
|
||||
this.pendingStop = { ws, promise, resolve };
|
||||
return promise;
|
||||
return this.pendingStop;
|
||||
}
|
||||
|
||||
private resolvePendingStop(ws: WebSocket): void {
|
||||
if (this.pendingStop?.ws !== ws) {
|
||||
return;
|
||||
}
|
||||
const { resolve } = this.pendingStop;
|
||||
const { resolve, terminateTimer } = this.pendingStop;
|
||||
if (terminateTimer) {
|
||||
clearTimeout(terminateTimer);
|
||||
}
|
||||
this.pendingStop = null;
|
||||
resolve();
|
||||
}
|
||||
|
||||
@@ -45,6 +45,36 @@ describe("cron protocol validators", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts command cron payloads", () => {
|
||||
expect(
|
||||
validateCronAddParams({
|
||||
...minimalAddParams,
|
||||
sessionTarget: "isolated",
|
||||
payload: {
|
||||
kind: "command",
|
||||
argv: ["sh", "-lc", "echo ok"],
|
||||
cwd: "/srv/example",
|
||||
env: { FOO: "bar" },
|
||||
input: "stdin",
|
||||
timeoutSeconds: 30,
|
||||
noOutputTimeoutSeconds: 5,
|
||||
outputMaxBytes: 4096,
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
validateCronUpdateParams({
|
||||
id: "job-1",
|
||||
patch: {
|
||||
payload: {
|
||||
kind: "command",
|
||||
argv: ["sh", "-lc", "echo updated"],
|
||||
},
|
||||
},
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects add params when required scheduling fields are missing", () => {
|
||||
const { wakeMode: _wakeMode, ...withoutWakeMode } = minimalAddParams;
|
||||
expect(validateCronAddParams(withoutWakeMode)).toBe(false);
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
formatValidationErrors,
|
||||
validateChatAbortParams,
|
||||
validateChatHistoryParams,
|
||||
validateChatMetadataParams,
|
||||
validateChatSendParams,
|
||||
validateChatEvent,
|
||||
validateCommandsListParams,
|
||||
@@ -104,6 +105,13 @@ describe("lazy protocol validators", () => {
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts selected-agent scope on chat metadata params", () => {
|
||||
expect(validateChatMetadataParams({})).toBe(true);
|
||||
expect(validateChatMetadataParams({ agentId: "work" })).toBe(true);
|
||||
expect(validateChatMetadataParams({ agentId: "" })).toBe(false);
|
||||
expect(validateChatMetadataParams({ agentId: "work", view: "configured" })).toBe(false);
|
||||
});
|
||||
|
||||
it("can still compile every exported protocol validator", () => {
|
||||
const failures: string[] = [];
|
||||
const validators: Array<[string, ProtocolValidator]> = [];
|
||||
|
||||
@@ -126,6 +126,8 @@ import {
|
||||
type ChatEvent,
|
||||
ChatEventSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
type ChatMetadataParams,
|
||||
ChatMetadataParamsSchema,
|
||||
ChatMessageGetResultSchema,
|
||||
ChatMessageGetParamsSchema,
|
||||
type ChatInjectParams,
|
||||
@@ -846,6 +848,7 @@ export const validateExecApprovalsNodeSetParams = lazyCompile<ExecApprovalsNodeS
|
||||
);
|
||||
export const validateLogsTailParams = lazyCompile<LogsTailParams>(LogsTailParamsSchema);
|
||||
export const validateChatHistoryParams = lazyCompile(ChatHistoryParamsSchema);
|
||||
export const validateChatMetadataParams = lazyCompile<ChatMetadataParams>(ChatMetadataParamsSchema);
|
||||
export const validateChatMessageGetParams = lazyCompile(ChatMessageGetParamsSchema);
|
||||
export const validateChatSendParams = lazyCompile(ChatSendParamsSchema);
|
||||
export const validateChatAbortParams = lazyCompile<ChatAbortParams>(ChatAbortParamsSchema);
|
||||
@@ -1115,6 +1118,7 @@ export {
|
||||
ExecApprovalRequestParamsSchema,
|
||||
ExecApprovalResolveParamsSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatMetadataParamsSchema,
|
||||
ChatSendParamsSchema,
|
||||
ChatInjectParamsSchema,
|
||||
UpdateRunParamsSchema,
|
||||
@@ -1223,6 +1227,7 @@ export type {
|
||||
ArtifactsDownloadResult,
|
||||
AgentsListParams,
|
||||
AgentsListResult,
|
||||
ChatMetadataParams,
|
||||
CommandsListParams,
|
||||
CommandsListResult,
|
||||
CommandEntry,
|
||||
|
||||
@@ -18,6 +18,22 @@ function cronAgentTurnPayloadSchema(params: { message: TSchema; toolsAllow: TSch
|
||||
);
|
||||
}
|
||||
|
||||
function cronCommandPayloadSchema(params: { argv: TSchema }) {
|
||||
return Type.Object(
|
||||
{
|
||||
kind: Type.Literal("command"),
|
||||
argv: params.argv,
|
||||
cwd: Type.Optional(Type.String({ minLength: 1 })),
|
||||
env: Type.Optional(Type.Record(Type.String({ minLength: 1 }), Type.String())),
|
||||
input: Type.Optional(Type.String()),
|
||||
timeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
noOutputTimeoutSeconds: Type.Optional(Type.Number({ minimum: 0 })),
|
||||
outputMaxBytes: Type.Optional(Type.Integer({ minimum: 1 })),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
}
|
||||
|
||||
const CronSessionTargetSchema = Type.Union([
|
||||
Type.Literal("main"),
|
||||
Type.Literal("isolated"),
|
||||
@@ -198,6 +214,9 @@ export const CronPayloadSchema = Type.Union([
|
||||
message: NonEmptyString,
|
||||
toolsAllow: Type.Array(Type.String()),
|
||||
}),
|
||||
cronCommandPayloadSchema({
|
||||
argv: Type.Array(NonEmptyString, { minItems: 1 }),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const CronPayloadPatchSchema = Type.Union([
|
||||
@@ -212,6 +231,9 @@ export const CronPayloadPatchSchema = Type.Union([
|
||||
message: Type.Optional(NonEmptyString),
|
||||
toolsAllow: Type.Union([Type.Array(Type.String()), Type.Null()]),
|
||||
}),
|
||||
cronCommandPayloadSchema({
|
||||
argv: Type.Optional(Type.Array(NonEmptyString, { minItems: 1 })),
|
||||
}),
|
||||
]);
|
||||
|
||||
export const CronFailureAlertSchema = Type.Object(
|
||||
|
||||
@@ -34,6 +34,13 @@ export const ChatHistoryParamsSchema = Type.Object(
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChatMetadataParamsSchema = Type.Object(
|
||||
{
|
||||
agentId: Type.Optional(NonEmptyString),
|
||||
},
|
||||
{ additionalProperties: false },
|
||||
);
|
||||
|
||||
export const ChatMessageGetParamsSchema = Type.Object(
|
||||
{
|
||||
sessionKey: NonEmptyString,
|
||||
|
||||
@@ -191,6 +191,7 @@ import {
|
||||
ChatEventSchema,
|
||||
ChatFinalEventSchema,
|
||||
ChatHistoryParamsSchema,
|
||||
ChatMetadataParamsSchema,
|
||||
ChatMessageGetParamsSchema,
|
||||
ChatMessageGetResultSchema,
|
||||
ChatInjectParamsSchema,
|
||||
@@ -534,6 +535,7 @@ export const ProtocolSchemas = {
|
||||
DevicePairRequestedEvent: DevicePairRequestedEventSchema,
|
||||
DevicePairResolvedEvent: DevicePairResolvedEventSchema,
|
||||
ChatHistoryParams: ChatHistoryParamsSchema,
|
||||
ChatMetadataParams: ChatMetadataParamsSchema,
|
||||
ChatMessageGetParams: ChatMessageGetParamsSchema,
|
||||
ChatMessageGetResult: ChatMessageGetResultSchema,
|
||||
ChatSendParams: ChatSendParamsSchema,
|
||||
|
||||
@@ -160,6 +160,7 @@ export type AgentsListResult = SchemaType<"AgentsListResult">;
|
||||
export type ModelChoice = SchemaType<"ModelChoice">;
|
||||
export type ModelsListParams = SchemaType<"ModelsListParams">;
|
||||
export type ModelsListResult = SchemaType<"ModelsListResult">;
|
||||
export type ChatMetadataParams = SchemaType<"ChatMetadataParams">;
|
||||
export type CommandEntry = SchemaType<"CommandEntry">;
|
||||
export type CommandsListParams = SchemaType<"CommandsListParams">;
|
||||
export type CommandsListResult = SchemaType<"CommandsListResult">;
|
||||
|
||||
@@ -12,6 +12,7 @@ coverage:
|
||||
objective: Verify the QA agent can respond correctly in a shared channel and respect mention-driven group semantics.
|
||||
successCriteria:
|
||||
- Agent replies in the shared channel transcript.
|
||||
- Agent visible reply contains the scenario marker.
|
||||
- Agent keeps the conversation scoped to the channel.
|
||||
- Agent respects mention-driven group routing semantics.
|
||||
docsRefs:
|
||||
@@ -24,7 +25,8 @@ execution:
|
||||
kind: flow
|
||||
summary: Verify the QA agent can respond correctly in a shared channel and respect mention-driven group semantics.
|
||||
config:
|
||||
mentionPrompt: "@openclaw explain the QA lab"
|
||||
expectedMarker: QA-CHANNEL-BASELINE-OK
|
||||
mentionPrompt: "@openclaw qa channel baseline marker check. Reply exactly: QA-CHANNEL-BASELINE-OK"
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
@@ -78,7 +80,14 @@ steps:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.conversation.id === 'qa-room' && !candidate.threadId"
|
||||
expr: "candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && candidate.conversation.kind === 'channel' && !candidate.threadId && String(candidate.text ?? '').includes(config.expectedMarker)"
|
||||
- expr: liveTurnTimeoutMs(env, 180000)
|
||||
- set: matchingOutbound
|
||||
value:
|
||||
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && candidate.conversation.kind === 'channel' && String(candidate.text ?? '').includes(config.expectedMarker))"
|
||||
- assert:
|
||||
expr: matchingOutbound.length === 1
|
||||
message:
|
||||
expr: "`expected exactly one channel baseline marker reply, saw ${matchingOutbound.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
|
||||
detailsExpr: message.text
|
||||
```
|
||||
|
||||
@@ -12,6 +12,7 @@ coverage:
|
||||
objective: Verify the QA agent can chat coherently in a DM, explain the QA setup, and stay in character.
|
||||
successCriteria:
|
||||
- Agent replies in DM without channel routing mistakes.
|
||||
- Agent visible reply contains the scenario marker.
|
||||
- Agent explains the QA lab and message bus correctly.
|
||||
- Agent keeps the dev C-3PO personality.
|
||||
docsRefs:
|
||||
@@ -24,7 +25,8 @@ execution:
|
||||
kind: flow
|
||||
summary: Verify the QA agent can chat coherently in a DM, explain the QA setup, and stay in character.
|
||||
config:
|
||||
prompt: "Hello there, who are you?"
|
||||
expectedMarker: QA-DM-BASELINE-OK
|
||||
prompt: "DM baseline marker check. Include exact marker: `QA-DM-BASELINE-OK` and briefly identify the QA lab message bus."
|
||||
```
|
||||
|
||||
```yaml qa-flow
|
||||
@@ -47,7 +49,14 @@ steps:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.conversation.id === 'alice'"
|
||||
expr: "candidate.direction === 'outbound' && candidate.conversation.id === 'alice' && candidate.conversation.kind === 'direct' && String(candidate.text ?? '').includes(config.expectedMarker)"
|
||||
- expr: liveTurnTimeoutMs(env, 45000)
|
||||
- set: matchingOutbound
|
||||
value:
|
||||
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'alice' && candidate.conversation.kind === 'direct' && String(candidate.text ?? '').includes(config.expectedMarker))"
|
||||
- assert:
|
||||
expr: matchingOutbound.length === 1
|
||||
message:
|
||||
expr: "`expected exactly one DM baseline marker reply, saw ${matchingOutbound.length}; transcript=${formatTransportTranscript(state, { conversationId: 'alice' })}`"
|
||||
detailsExpr: outbound.text
|
||||
```
|
||||
|
||||
@@ -64,7 +64,7 @@ steps:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound'"
|
||||
expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound' && String(candidate.text ?? '').includes(config.firstMarker)"
|
||||
- expr: liveTurnTimeoutMs(env, 60000)
|
||||
- set: beforeRestartCursor
|
||||
value:
|
||||
@@ -80,9 +80,9 @@ steps:
|
||||
value:
|
||||
expr: "state.getSnapshot().messages.filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room')"
|
||||
- assert:
|
||||
expr: "firstMatchesBeforeFollowup.length === 1"
|
||||
expr: "firstMatchesBeforeFollowup.length === 1 && String(firstMatchesBeforeFollowup[0]?.text ?? '').includes(config.firstMarker)"
|
||||
message:
|
||||
expr: "`readiness cycle replayed first reply ${firstMatchesBeforeFollowup.length} times; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
|
||||
expr: "`readiness cycle should preserve exactly one marked first reply, saw ${firstMatchesBeforeFollowup.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
|
||||
- call: runAgentPrompt
|
||||
args:
|
||||
- ref: env
|
||||
@@ -99,7 +99,7 @@ steps:
|
||||
- ref: state
|
||||
- lambda:
|
||||
params: [candidate]
|
||||
expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound'"
|
||||
expr: "candidate.conversation.id === 'qa-room' && candidate.direction === 'outbound' && String(candidate.text ?? '').includes(config.secondMarker)"
|
||||
- expr: liveTurnTimeoutMs(env, 60000)
|
||||
- sinceIndex:
|
||||
ref: beforeRestartCursor
|
||||
@@ -108,13 +108,16 @@ steps:
|
||||
expr: state.getSnapshot()
|
||||
- set: firstMatches
|
||||
value:
|
||||
expr: "snapshot.messages.slice(0, beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room')"
|
||||
expr: "snapshot.messages.slice(0, beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && String(candidate.text ?? '').includes(config.firstMarker))"
|
||||
- set: secondMatches
|
||||
value:
|
||||
expr: "snapshot.messages.slice(beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room' && String(candidate.text ?? '').includes(config.secondMarker))"
|
||||
- set: postRestartOutbounds
|
||||
value:
|
||||
expr: "snapshot.messages.slice(beforeRestartCursor).filter((candidate) => candidate.direction === 'outbound' && candidate.conversation.id === 'qa-room')"
|
||||
- assert:
|
||||
expr: "firstMatches.length === 1 && secondMatches.length === 1"
|
||||
expr: "firstMatches.length === 1 && secondMatches.length === 1 && postRestartOutbounds.length === 1 && !postRestartOutbounds.some((candidate) => String(candidate.text ?? '').includes(config.firstMarker))"
|
||||
message:
|
||||
expr: "`expected one pre-restart and one post-restart reply; first=${firstMatches.length} second=${secondMatches.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
|
||||
expr: "`expected one marked pre-restart reply and exactly one marked post-restart reply without replaying the first marker; first=${firstMatches.length} second=${secondMatches.length} post=${postRestartOutbounds.length}; transcript=${formatTransportTranscript(state, { conversationId: 'qa-room' })}`"
|
||||
detailsExpr: "`before=${firstOutbound.text}\\nafter=${secondOutbound.text}`"
|
||||
```
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user