mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-16 19:18:54 +08:00
Compare commits
1 Commits
codex/matt
...
codex-netw
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9850eb63fc |
@@ -16,15 +16,6 @@ Use this with `$release-openclaw-maintainer` and `$openclaw-testing` when a rele
|
||||
- Watch one parent run plus compact child summaries. Avoid broad `gh run view` polling loops; REST quota is easy to burn.
|
||||
- Fetch logs only for failed or currently-blocking jobs. If quota is low, stop polling and wait for reset.
|
||||
- Treat live-provider flakes separately from code failures: prove key validity, provider HTTP status, retry evidence, and exact failing lane before editing code.
|
||||
- Anthropic release lanes support both API keys and OAuth. When API keys are
|
||||
exhausted but a maintainer-owned OAuth token passes a live Anthropic probe,
|
||||
set `ANTHROPIC_OAUTH_TOKEN` for provider/runtime lanes and
|
||||
refreshable `OPENCLAW_CLAUDE_CREDENTIALS_JSON` or
|
||||
`CLAUDE_CODE_OAUTH_TOKEN` for Claude CLI subscription lanes before rerunning
|
||||
the matrix. Revalidate short-lived OAuth immediately before dispatch. Never
|
||||
keep retrying a known exhausted API key. Live-cache validation must prefer
|
||||
the proven OAuth token instead of leaving an exhausted API key first in the
|
||||
runtime key pool.
|
||||
- Full Release Validation parent monitors fail fast: once a required child job
|
||||
fails, the parent cancels the remaining child matrix and prints the failed
|
||||
job summary. Inspect that first red job instead of waiting for unrelated
|
||||
@@ -45,8 +36,6 @@ git rev-parse HEAD
|
||||
preflight. Inject those exact targeted keys first, then run the verifier; use
|
||||
ambient env only when it was already intentionally injected for this release.
|
||||
The script prints only provider status and HTTP class, never tokens.
|
||||
For Anthropic it prefers `ANTHROPIC_OAUTH_TOKEN` and validates it with bearer
|
||||
OAuth headers when present; otherwise it checks API-key-shaped credentials.
|
||||
|
||||
## Dispatch
|
||||
|
||||
@@ -125,10 +114,6 @@ Stop watchers before ending the turn or switching strategy.
|
||||
```
|
||||
3. Fetch one failed job log. If rate-limited, note reset time and avoid more REST calls.
|
||||
4. For secret-looking failures, validate the provider endpoint from the same secret source before editing code.
|
||||
For Docker CLI-backend failures, also validate
|
||||
`OPENCLAW_CLAUDE_CREDENTIALS_JSON` or `CLAUDE_CODE_OAUTH_TOKEN` in a
|
||||
clean-home Claude CLI probe; that lane should use subscription mode when
|
||||
either credential exists.
|
||||
5. For live-cache failures, inspect whether it is missing/invalid key, empty text, provider refusal, timeout, or baseline miss. Do not weaken release gates without clear provider evidence.
|
||||
6. Fix narrowly, run local/changed proof, commit, push, rerun the smallest matching group.
|
||||
|
||||
|
||||
@@ -42,7 +42,7 @@ async function checkProvider(id, config) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const headers = config.headers(secret);
|
||||
const headers = config.headers(secret.value);
|
||||
const response = await fetch(config.url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
@@ -69,32 +69,25 @@ const providers = {
|
||||
openai: {
|
||||
env: ["OPENAI_API_KEY"],
|
||||
url: "https://api.openai.com/v1/models",
|
||||
headers: ({ value }) => ({ authorization: `Bearer ${value}` }),
|
||||
headers: (token) => ({ authorization: `Bearer ${token}` }),
|
||||
},
|
||||
anthropic: {
|
||||
env: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
|
||||
env: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
|
||||
url: "https://api.anthropic.com/v1/models",
|
||||
headers: ({ name, value }) =>
|
||||
name === "ANTHROPIC_OAUTH_TOKEN"
|
||||
? {
|
||||
"anthropic-beta": "oauth-2025-04-20",
|
||||
"anthropic-version": "2023-06-01",
|
||||
authorization: `Bearer ${value}`,
|
||||
}
|
||||
: {
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": value,
|
||||
},
|
||||
headers: (token) => ({
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": token,
|
||||
}),
|
||||
},
|
||||
fireworks: {
|
||||
env: ["FIREWORKS_API_KEY"],
|
||||
url: "https://api.fireworks.ai/inference/v1/models",
|
||||
headers: ({ value }) => ({ authorization: `Bearer ${value}` }),
|
||||
headers: (token) => ({ authorization: `Bearer ${token}` }),
|
||||
},
|
||||
openrouter: {
|
||||
env: ["OPENROUTER_API_KEY"],
|
||||
url: "https://openrouter.ai/api/v1/models",
|
||||
headers: ({ value }) => ({ authorization: `Bearer ${value}` }),
|
||||
headers: (token) => ({ authorization: `Bearer ${token}` }),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -61,7 +61,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
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 \
|
||||
@@ -188,7 +188,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
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"
|
||||
@@ -210,7 +210,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
|
||||
5
.github/workflows/ci-check-arm-testbox.yml
vendored
5
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -76,7 +76,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
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 \
|
||||
@@ -106,7 +106,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
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"
|
||||
@@ -128,7 +128,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
|
||||
5
.github/workflows/ci-check-testbox.yml
vendored
5
.github/workflows/ci-check-testbox.yml
vendored
@@ -61,7 +61,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
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 \
|
||||
@@ -91,7 +91,7 @@ jobs:
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
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"
|
||||
@@ -113,7 +113,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
|
||||
30
.github/workflows/ci.yml
vendored
30
.github/workflows/ci.yml
vendored
@@ -90,7 +90,7 @@ jobs:
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=2 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
@@ -351,7 +351,7 @@ jobs:
|
||||
local ref="$1"
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${ref}:refs/remotes/origin/checkout" && return 0
|
||||
@@ -499,7 +499,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -564,7 +564,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -810,7 +810,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -899,7 +899,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -979,7 +979,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1056,7 +1056,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1131,7 +1131,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1258,7 +1258,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1399,7 +1399,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1584,7 +1584,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
@@ -1630,7 +1630,7 @@ jobs:
|
||||
git -C "$workdir" config gc.auto 0
|
||||
git -C "$workdir" remote add origin "https://github.com/openclaw/clawhub.git"
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/checkout" || return 1
|
||||
@@ -1677,7 +1677,7 @@ jobs:
|
||||
fetch_checkout_ref() {
|
||||
local fetch_status
|
||||
for attempt in 1 2 3; do
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$GITHUB_WORKSPACE" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/checkout" && return 0
|
||||
@@ -2083,7 +2083,7 @@ jobs:
|
||||
git -C "$workdir" remote add origin "https://github.com/${CHECKOUT_REPO}.git"
|
||||
git -C "$workdir" config gc.auto 0
|
||||
|
||||
timeout --signal=TERM --kill-after=10s 120s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 30s git -C "$workdir" \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=1 origin \
|
||||
"+${CHECKOUT_SHA}:refs/remotes/origin/ci-target" || return 1
|
||||
|
||||
1
.github/workflows/crabbox-hydrate.yml
vendored
1
.github/workflows/crabbox-hydrate.yml
vendored
@@ -663,7 +663,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
|
||||
6
.github/workflows/install-smoke.yml
vendored
6
.github/workflows/install-smoke.yml
vendored
@@ -476,21 +476,19 @@ jobs:
|
||||
- name: Run Rocky Linux installer smoke
|
||||
run: |
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install.sh:/tmp/install.sh:ro" \
|
||||
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
|
||||
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
|
||||
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install.sh --install-method npm --version latest --no-onboard --no-prompt --verify && openclaw --version'
|
||||
|
||||
- name: Run Rocky Linux CLI installer smoke
|
||||
run: |
|
||||
timeout --kill-after=30s 20m docker run --rm \
|
||||
--platform linux/amd64 \
|
||||
-e OPENCLAW_NO_ONBOARD=1 \
|
||||
-e OPENCLAW_NO_PROMPT=1 \
|
||||
-v "$PWD/scripts/install-cli.sh:/tmp/install-cli.sh:ro" \
|
||||
rockylinux:9@sha256:d644d203142cd5b54ad2a83a203e1dee68af2229f8fe32f52a30c6e1d3c3a9e0 \
|
||||
rockylinux:9@sha256:d7be1c094cc5845ee815d4632fe377514ee6ebcf8efaed6892889657e5ddaaa6 \
|
||||
bash -lc 'dnf install -y -q ca-certificates tar gzip xz findutils which sudo >/dev/null && bash /tmp/install-cli.sh --prefix /tmp/openclaw-cli --version latest --no-onboard && /tmp/openclaw-cli/bin/openclaw --version'
|
||||
|
||||
bun_global_install_smoke:
|
||||
|
||||
@@ -229,8 +229,6 @@ on:
|
||||
required: false
|
||||
ANTHROPIC_API_TOKEN:
|
||||
required: false
|
||||
ANTHROPIC_OAUTH_TOKEN:
|
||||
required: false
|
||||
FACTORY_API_KEY:
|
||||
required: false
|
||||
BYTEPLUS_API_KEY:
|
||||
@@ -521,7 +519,6 @@ jobs:
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
OPENCLAW_LIVE_CACHE_TEST: "1"
|
||||
OPENCLAW_LIVE_TEST: "1"
|
||||
steps:
|
||||
@@ -544,13 +541,10 @@ jobs:
|
||||
echo "Missing OPENAI_API_KEY secret for live-cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ANTHROPIC_OAUTH_TOKEN:-}" && -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
echo "Missing ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY secret for live-cache validation." >&2
|
||||
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
echo "Missing ANTHROPIC_API_KEY secret for live-cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ]]; then
|
||||
echo "ANTHROPIC_API_KEY=" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Verify live prompt cache floors
|
||||
run: |
|
||||
@@ -686,7 +680,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
@@ -951,7 +944,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
@@ -1663,7 +1655,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -1755,7 +1746,7 @@ jobs:
|
||||
}
|
||||
|
||||
case "${LIVE_MODEL_PROVIDERS}" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
@@ -1787,7 +1778,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -1931,7 +1921,7 @@ jobs:
|
||||
IFS=',' read -r -a providers <<<"${OPENCLAW_LIVE_PROVIDERS}"
|
||||
for provider in "${providers[@]}"; do
|
||||
case "$provider" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
google) require_any Google GEMINI_API_KEY GOOGLE_API_KEY ;;
|
||||
minimax) require_any MiniMax MINIMAX_API_KEY ;;
|
||||
moonshot) require_any Moonshot MOONSHOT_API_KEY KIMI_API_KEY ;;
|
||||
@@ -2150,7 +2140,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -2233,11 +2222,7 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
@@ -2371,7 +2356,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -2463,11 +2447,7 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
if [[ -n "${OPENCLAW_CLAUDE_CREDENTIALS_JSON:-}" || -n "${CLAUDE_CODE_OAUTH_TOKEN:-}" ]]; then
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=subscription" >> "$GITHUB_ENV"
|
||||
else
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
fi
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
@@ -2588,7 +2568,6 @@ jobs:
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
|
||||
@@ -631,7 +631,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
@@ -725,7 +724,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
|
||||
@@ -38,7 +38,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
|
||||
3
.github/workflows/package-acceptance.yml
vendored
3
.github/workflows/package-acceptance.yml
vendored
@@ -203,8 +203,6 @@ on:
|
||||
required: false
|
||||
ANTHROPIC_API_TOKEN:
|
||||
required: false
|
||||
ANTHROPIC_OAUTH_TOKEN:
|
||||
required: false
|
||||
FACTORY_API_KEY:
|
||||
required: false
|
||||
BYTEPLUS_API_KEY:
|
||||
@@ -590,7 +588,6 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
ANTHROPIC_OAUTH_TOKEN: ${{ secrets.ANTHROPIC_OAUTH_TOKEN }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
|
||||
@@ -34,7 +34,6 @@ Docs: https://docs.openclaw.ai
|
||||
- Memory, state, diagnostics, and config: split header-too-large embedding batches, keep QMD memory search enabled in transient mode, avoid SQLite WAL on NFS volumes, preserve recovery scheduling outside stuck-session warning backoff, and keep shell environment fallbacks contained in config write tests. (#92650, #92618, #92639, #91247, #92752) Thanks @mushuiyu886, @TurboTheTurtle, @849261680, and @gnanam1990.
|
||||
- Workspace setup state: store setup completion outside the workspace dot directory using an OpenClaw-named root file, migrate valid legacy state forward, and avoid clobbering generic root `workspace-state.json` files for TigerFS-style dot-path compatibility. This Clownfish replacement carries forward the focused #53326 fix idea because the original branch was closed and uneditable. (#53326, #44783, #39446) Thanks @1qh.
|
||||
- UI/mobile/TUI: preserve dashboard session parent lineage, WebChat backscroll, reset soft command args, sidebar session picker interactivity, collapsed workspace files, resolved `/model` confirmation refs, and stale foreground iOS Gateway reconnects. (#90658, #92622, #91353, #92705, #92779, #92773, #92552) Thanks @luoyanglang, @TurboTheTurtle, @zhouhe-xydt, @NianJiuZst, @shakkernerd, @NarahariRaghava, and @Solvely-Colin.
|
||||
- TUI: reload the active session after external `/new` or `/reset` session-change events so stale transcript and stream state clear promptly. Fixes #38966; carries forward #40472. Thanks @yizhanzjz and @wsyjh8.
|
||||
- Control UI: preserve Gateway Access tokens during same-normalized WebSocket URL edits and reload gateway-scoped tokens when switching endpoints. Fixes #41545; repairs #42001 with additional source PRs #41546, #41552, and #41718. Thanks @wsyjh8, @llagy0020, @llagy007, @pingfanfan, and @zheliu2.
|
||||
- Gateway CLI: tolerate a single transient clean WebSocket close before `hello-ok` so one-shot RPC calls reconnect instead of failing noisily, while repeated clean pre-hello closes still surface. Carries forward source PRs #54475 and #54774; #85253 covered adjacent connect assembly diagnostics. Thanks @ruanrrn.
|
||||
- Release and test reliability: extend slow Gateway/full-suite watchdogs, split local full-suite shards when throttled, stabilize plugin auth marker fixtures, avoid brittle provider-ref error text, and keep QA Lab bootstrap selection assertions aligned with flow-only scenarios. (#92652)
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
9eaddb6e9ad300ea9cb113c78a84e8a104cb3efab843ec0a0edd9947c7731fc8 plugin-sdk-api-baseline.json
|
||||
8e7b3e5c86a9039a0ce8a134dad79ef65fc72c085d044346f8dbfa88d6fccf1b plugin-sdk-api-baseline.jsonl
|
||||
ea92ef67bc01141e1f3b64edd025471c9c3439da50de3cecb208e7eca797f947 plugin-sdk-api-baseline.json
|
||||
28ed6df6ba46abfd252cd760b7f88a93c598b4256e6ea8dfc2c9005c327300fb plugin-sdk-api-baseline.jsonl
|
||||
|
||||
@@ -122,7 +122,7 @@ openclaw sessions cleanup --json
|
||||
- Cleanup also prunes unreferenced primary transcripts, compaction checkpoints, and trajectory sidecars older than `session.maintenance.pruneAfter`; files still referenced by `sessions.json` are preserved.
|
||||
|
||||
- `--dry-run`: preview how many entries would be pruned/capped without writing.
|
||||
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) plus a summary grouped by session label so you can see what would be kept vs removed.
|
||||
- In text mode, dry-run prints a per-session action table (`Action`, `Key`, `Age`, `Model`, `Flags`) so you can see what would be kept vs removed.
|
||||
- `--enforce`: apply maintenance even when `session.maintenance.mode` is `warn`.
|
||||
- `--fix-missing`: remove entries whose transcript files are missing or header-only/empty, even if they would not normally age/count out yet.
|
||||
- `--fix-dm-scope`: when `session.dmScope` is `main`, retire stale peer-keyed direct-DM rows left behind by earlier `per-peer`, `per-channel-peer`, or `per-account-channel-peer` routing. Use `--dry-run` first; applying the cleanup removes those rows from `sessions.json` and preserves their transcripts as deleted archives.
|
||||
|
||||
@@ -824,7 +824,7 @@ The live-model Docker runners also bind-mount only the needed CLI auth homes (or
|
||||
- Release user journey smoke: `pnpm test:docker:release-user-journey` installs the packed OpenClaw tarball globally in a clean Docker home, runs onboarding, configures a mocked OpenAI provider, runs an agent turn, installs/uninstalls external plugins, configures ClickClack against a local fixture, verifies outbound/inbound messaging, restarts Gateway, and runs doctor.
|
||||
- Release typed onboarding smoke: `pnpm test:docker:release-typed-onboarding` installs the packed tarball, drives `openclaw onboard` through a real TTY, configures OpenAI as an env-ref provider, verifies no raw key persistence, and runs a mocked agent turn.
|
||||
- Release media/memory smoke: `pnpm test:docker:release-media-memory` installs the packed tarball, verifies image understanding from a PNG attachment, OpenAI-compatible image generation output, memory search recall, and recall survival across Gateway restart.
|
||||
- Release upgrade user journey smoke: `pnpm test:docker:release-upgrade-user-journey` installs the newest published baseline older than the candidate tarball by default, configures provider/plugin/ClickClack state on the published package, upgrades to the candidate tarball, then reruns the core agent/plugin/channel journey. If no older published baseline exists, it reuses the candidate version. Override the baseline with `OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=openclaw@<version>`.
|
||||
- Release upgrade user journey smoke: `pnpm test:docker:release-upgrade-user-journey` installs `openclaw@latest` by default, configures provider/plugin/ClickClack state on the published package, upgrades to the candidate tarball, then reruns the core agent/plugin/channel journey. Override the baseline with `OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=openclaw@<version>`.
|
||||
- Release plugin marketplace smoke: `pnpm test:docker:release-plugin-marketplace` installs from a local fixture marketplace, updates the installed plugin, uninstalls it, and verifies the plugin CLI disappears with install metadata pruned.
|
||||
- Skill install smoke: `pnpm test:docker:skill-install` installs the packed OpenClaw tarball globally in Docker, disables uploaded archive installs in config, resolves the current live ClawHub skill slug from search, installs it with `openclaw skills install`, and verifies the installed skill plus `.clawhub` origin/lock metadata.
|
||||
- Update channel switch smoke: `pnpm test:docker:update-channel-switch` installs the packed OpenClaw tarball globally in Docker, switches from package `stable` to git `dev`, verifies the persisted channel and plugin post-update work, then switches back to package `stable` and checks update status.
|
||||
|
||||
@@ -103,8 +103,45 @@ Supported `appServer` fields:
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed. |
|
||||
| `defaultWorkspaceDir` | current process directory | Workspace used by `/codex bind` when `--cwd` is omitted. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, and `null` clears the override. Legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects that profile on thread start or resume instead of sending `sandbox`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
|
||||
`appServer.networkProxy` is explicit because it changes the Codex sandbox
|
||||
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` in
|
||||
the Codex thread config so the generated permission profile can start Codex
|
||||
managed networking. The default generated profile is `openclaw-network`; use
|
||||
`profileName` to choose another local name.
|
||||
|
||||
```js
|
||||
export default {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
If the normal app-server runtime would be `danger-full-access`, enabling
|
||||
`networkProxy` uses workspace-style filesystem access for the generated
|
||||
permission profile. Codex managed network enforcement is sandboxed networking,
|
||||
so a full-access profile would not protect outbound traffic.
|
||||
|
||||
The plugin blocks older or unversioned app-server handshakes. Codex app-server
|
||||
must report stable version `0.125.0` or newer.
|
||||
|
||||
|
||||
@@ -561,8 +561,45 @@ Supported `appServer` fields:
|
||||
| `sandbox` | `"danger-full-access"` or an allowed guardian sandbox | Native Codex sandbox mode sent to thread start/resume. Guardian defaults prefer `"workspace-write"` when allowed, otherwise `"read-only"`. When an OpenClaw sandbox is active, `danger-full-access` turns use Codex `workspace-write` with network access derived from the OpenClaw sandbox egress setting. |
|
||||
| `approvalsReviewer` | `"user"` or an allowed guardian reviewer | Use `"auto_review"` to let Codex review native approval prompts when allowed, otherwise `guardian_subagent` or `user`. `guardian_subagent` remains a legacy alias. |
|
||||
| `serviceTier` | unset | Optional Codex app-server service tier. `"priority"` enables fast-mode routing, `"flex"` requests flex processing, `null` clears the override, and legacy `"fast"` is accepted as `"priority"`. |
|
||||
| `networkProxy` | disabled | Opt into Codex permissions-profile networking for app-server commands. OpenClaw defines the selected `permissions.<profile>.network` config and selects that profile on thread start or resume instead of sending `sandbox`. |
|
||||
| `experimental.sandboxExecServer` | `false` | Preview opt-in that registers an OpenClaw sandbox-backed Codex environment with Codex app-server 0.132.0 or newer so native Codex execution can run inside the active OpenClaw sandbox. |
|
||||
|
||||
`appServer.networkProxy` is explicit because it changes the Codex sandbox
|
||||
contract. When enabled, OpenClaw also sets `features.network_proxy.enabled` in
|
||||
the Codex thread config so the generated permission profile can start Codex
|
||||
managed networking. The default generated profile is `openclaw-network`; use
|
||||
`profileName` to choose another local name.
|
||||
|
||||
```js
|
||||
export default {
|
||||
plugins: {
|
||||
entries: {
|
||||
codex: {
|
||||
config: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
If the normal app-server runtime would be `danger-full-access`, enabling
|
||||
`networkProxy` uses workspace-style filesystem access for the generated
|
||||
permission profile. Codex managed network enforcement is sandboxed networking,
|
||||
so a full-access profile would not protect outbound traffic.
|
||||
|
||||
OpenClaw-owned dynamic tool calls are bounded independently from
|
||||
`appServer.requestTimeoutMs`: Codex `item/tool/call` requests use a 90 second
|
||||
OpenClaw watchdog by default. A positive per-call `timeoutMs` argument extends
|
||||
|
||||
@@ -108,47 +108,3 @@ describe("bedrock embedding response parsers", () => {
|
||||
).toThrow("Amazon Bedrock embedding response returned malformed JSON");
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripInferenceProfilePrefix", () => {
|
||||
it("strips global prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("global.cohere.embed-v4:0")).toBe(
|
||||
"cohere.embed-v4:0",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips us prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("us.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips eu prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("eu.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips ap prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("ap.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips apac prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("apac.cohere.embed-v4:0")).toBe(
|
||||
"cohere.embed-v4:0",
|
||||
);
|
||||
});
|
||||
|
||||
it("strips au prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("au.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("strips jp prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("jp.cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("returns unchanged model ID without prefix", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("cohere.embed-v4:0")).toBe("cohere.embed-v4:0");
|
||||
});
|
||||
|
||||
it("returns unchanged model ID for amazon.titan-embed-text-v2:0", () => {
|
||||
expect(testing.stripInferenceProfilePrefix("amazon.titan-embed-text-v2:0")).toBe(
|
||||
"amazon.titan-embed-text-v2:0",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -69,18 +69,12 @@ const MODELS: Record<string, ModelSpec> = {
|
||||
"twelvelabs.marengo-embed-3-0-v1:0": { maxTokens: 512, dims: 512, family: "twelvelabs" },
|
||||
};
|
||||
|
||||
/** Strip AWS inference profile prefix (us., eu., ap., apac., au., jp., global.) from model ID. */
|
||||
function stripInferenceProfilePrefix(modelId: string): string {
|
||||
return modelId.replace(/^(?:us|eu|ap|apac|au|jp|global)\./, "");
|
||||
}
|
||||
|
||||
/** Resolve spec, stripping throughput suffixes like `:2:8k` or `:0:512`. */
|
||||
function resolveSpec(modelId: string): ModelSpec | undefined {
|
||||
const bare = stripInferenceProfilePrefix(modelId);
|
||||
if (MODELS[bare]) {
|
||||
return MODELS[bare];
|
||||
if (MODELS[modelId]) {
|
||||
return MODELS[modelId];
|
||||
}
|
||||
const parts = bare.split(":");
|
||||
const parts = modelId.split(":");
|
||||
for (let i = parts.length - 1; i >= 1; i--) {
|
||||
const spec = MODELS[parts.slice(0, i).join(":")];
|
||||
if (spec) {
|
||||
@@ -92,7 +86,7 @@ function resolveSpec(modelId: string): ModelSpec | undefined {
|
||||
|
||||
/** Infer family from model ID prefix when not in catalog. */
|
||||
function inferFamily(modelId: string): Family {
|
||||
const id = normalizeLowercaseStringOrEmpty(stripInferenceProfilePrefix(modelId));
|
||||
const id = normalizeLowercaseStringOrEmpty(modelId);
|
||||
if (id.startsWith("amazon.titan-embed-text-v2")) {
|
||||
return "titan-v2";
|
||||
}
|
||||
@@ -318,7 +312,6 @@ function parseCohereBatch(family: Family, raw: string): number[][] {
|
||||
export const testing = {
|
||||
parseCohereBatch,
|
||||
parseSingle,
|
||||
stripInferenceProfilePrefix,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -193,6 +193,47 @@
|
||||
"enum": ["user", "auto_review", "guardian_subagent"]
|
||||
},
|
||||
"serviceTier": { "type": ["string", "null"] },
|
||||
"networkProxy": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"enabled": {
|
||||
"type": "boolean",
|
||||
"default": false
|
||||
},
|
||||
"profileName": { "type": "string" },
|
||||
"baseProfile": {
|
||||
"type": "string",
|
||||
"enum": ["read-only", "workspace"]
|
||||
},
|
||||
"mode": {
|
||||
"type": "string",
|
||||
"enum": ["limited", "full"]
|
||||
},
|
||||
"domains": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"]
|
||||
}
|
||||
},
|
||||
"unixSockets": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"type": "string",
|
||||
"enum": ["allow", "deny"]
|
||||
}
|
||||
},
|
||||
"proxyUrl": { "type": "string" },
|
||||
"socksUrl": { "type": "string" },
|
||||
"enableSocks5": { "type": "boolean" },
|
||||
"enableSocks5Udp": { "type": "boolean" },
|
||||
"allowUpstreamProxy": { "type": "boolean" },
|
||||
"allowLocalBinding": { "type": "boolean" },
|
||||
"dangerouslyAllowNonLoopbackProxy": { "type": "boolean" },
|
||||
"dangerouslyAllowAllUnixSockets": { "type": "boolean" }
|
||||
}
|
||||
},
|
||||
"defaultWorkspaceDir": {
|
||||
"type": "string"
|
||||
},
|
||||
@@ -385,6 +426,81 @@
|
||||
"help": "Optional Codex app-server service tier. Use priority, flex, or null. Legacy fast is accepted as priority.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy": {
|
||||
"label": "Network Proxy",
|
||||
"help": "Enable Codex permissions-profile networking for app-server commands.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enabled": {
|
||||
"label": "Network Proxy Enabled",
|
||||
"help": "When enabled, OpenClaw defines a Codex permissions profile and selects it on thread start or resume instead of sandbox fields.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.profileName": {
|
||||
"label": "Network Proxy Profile",
|
||||
"help": "Codex permissions profile name generated for app-server network access.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.baseProfile": {
|
||||
"label": "Network Proxy Base",
|
||||
"help": "Filesystem access used by the generated profile. Defaults to read-only for read-only sandboxes and workspace otherwise.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.domains": {
|
||||
"label": "Network Domains",
|
||||
"help": "Domain allow and deny rules for Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.unixSockets": {
|
||||
"label": "Unix Sockets",
|
||||
"help": "Unix socket allow and deny rules for Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.proxyUrl": {
|
||||
"label": "HTTP Proxy URL",
|
||||
"help": "HTTP listener URL used by Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.socksUrl": {
|
||||
"label": "SOCKS Proxy URL",
|
||||
"help": "SOCKS listener URL used by Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enableSocks5": {
|
||||
"label": "Enable SOCKS5",
|
||||
"help": "Expose SOCKS5 support for the generated Codex permissions profile.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.enableSocks5Udp": {
|
||||
"label": "Enable SOCKS5 UDP",
|
||||
"help": "Allow UDP over the SOCKS5 listener when SOCKS5 is enabled.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.allowUpstreamProxy": {
|
||||
"label": "Allow Upstream Proxy",
|
||||
"help": "Allow Codex sandboxed networking to chain through inherited HTTP(S)_PROXY or ALL_PROXY settings.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.allowLocalBinding": {
|
||||
"label": "Allow Local Binding",
|
||||
"help": "Permit broader local and private-network access through Codex sandboxed networking.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.mode": {
|
||||
"label": "Network Mode",
|
||||
"help": "Codex sandboxed networking mode for subprocess traffic.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.dangerouslyAllowNonLoopbackProxy": {
|
||||
"label": "Allow Non-Loopback Proxy",
|
||||
"help": "Permit non-loopback bind addresses for Codex sandboxed networking listeners.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.networkProxy.dangerouslyAllowAllUnixSockets": {
|
||||
"label": "Allow All Unix Sockets",
|
||||
"help": "Bypass Codex's Unix socket allowlist for tightly controlled environments.",
|
||||
"advanced": true
|
||||
},
|
||||
"appServer.defaultWorkspaceDir": {
|
||||
"label": "Default Workspace",
|
||||
"help": "Workspace used by /codex bind when --cwd is omitted.",
|
||||
|
||||
@@ -218,12 +218,17 @@ function resolveBoundedThreadConfig(
|
||||
params: CodexBoundedTurnParams,
|
||||
workspace: { codexHome?: string },
|
||||
): JsonObject {
|
||||
const boundedConfig =
|
||||
mergeCodexThreadConfigs(CODEX_BOUNDED_THREAD_CONFIG, params.threadConfig) ??
|
||||
CODEX_BOUNDED_THREAD_CONFIG;
|
||||
return workspace.codexHome
|
||||
? (mergeCodexThreadConfigs(boundedConfig, CODEX_PRIVATE_BOUNDED_THREAD_CONFIG) ?? boundedConfig)
|
||||
: boundedConfig;
|
||||
const boundedConfig = mergeCodexThreadConfigs(
|
||||
CODEX_BOUNDED_THREAD_CONFIG,
|
||||
params.threadConfig,
|
||||
) ?? { ...CODEX_BOUNDED_THREAD_CONFIG };
|
||||
if (!workspace.codexHome) {
|
||||
return boundedConfig;
|
||||
}
|
||||
return mergeCodexThreadConfigs(boundedConfig, CODEX_PRIVATE_BOUNDED_THREAD_CONFIG) ?? {
|
||||
...boundedConfig,
|
||||
...CODEX_PRIVATE_BOUNDED_THREAD_CONFIG,
|
||||
};
|
||||
}
|
||||
|
||||
function buildPrivateCodexAppServerStartOptions(
|
||||
|
||||
@@ -125,6 +125,89 @@ describe("Codex app-server config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("builds Codex permissions-profile config for app-server network proxy", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
sandbox: "workspace-write",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
profileName: "mock-proxy",
|
||||
mode: "limited",
|
||||
domains: {
|
||||
" api.openai.com ": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unixSockets: {
|
||||
" /tmp/mock-proxy.sock ": "allow",
|
||||
},
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
socksUrl: "socks5h://127.0.0.1:8081",
|
||||
enableSocks5: true,
|
||||
enableSocks5Udp: false,
|
||||
allowUpstreamProxy: true,
|
||||
allowLocalBinding: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(runtime.networkProxy).toEqual({
|
||||
profileName: "mock-proxy",
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":workspace_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
mode: "limited",
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
"blocked.example.com": "deny",
|
||||
},
|
||||
unix_sockets: {
|
||||
"/tmp/mock-proxy.sock": "allow",
|
||||
},
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
socks_url: "socks5h://127.0.0.1:8081",
|
||||
enable_socks5: true,
|
||||
enable_socks5_udp: false,
|
||||
allow_upstream_proxy: true,
|
||||
allow_local_binding: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses read-only filesystem rules for read-only network proxy profiles", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
sandbox: "read-only",
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "example.com": "allow" },
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
const permissions = runtime.networkProxy?.configPatch.permissions as Record<
|
||||
string,
|
||||
{ filesystem: { ":workspace_roots": { ".": string } } }
|
||||
>;
|
||||
|
||||
expect(runtime.networkProxy?.profileName).toBe("openclaw-network");
|
||||
expect(permissions["openclaw-network"]?.filesystem[":workspace_roots"]["."]).toBe("read");
|
||||
});
|
||||
|
||||
it("clamps oversized app-server timer config", () => {
|
||||
const runtime = resolveRuntimeForTest({
|
||||
pluginConfig: {
|
||||
|
||||
@@ -16,7 +16,7 @@ import { normalizeAgentId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeTrimmedStringList } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { detectWindowsSpawnCommandInlineArgs } from "openclaw/plugin-sdk/windows-spawn";
|
||||
import { z } from "zod";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier, JsonObject, JsonValue } from "./protocol.js";
|
||||
|
||||
const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret");
|
||||
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
|
||||
@@ -111,6 +111,32 @@ export type CodexAppServerExperimentalConfig = {
|
||||
sandboxExecServer?: boolean;
|
||||
};
|
||||
|
||||
export type CodexAppServerNetworkProxyPermission = "allow" | "deny";
|
||||
export type CodexAppServerNetworkProxyBaseProfile = "read-only" | "workspace";
|
||||
export type CodexAppServerNetworkProxyMode = "limited" | "full";
|
||||
|
||||
export type CodexAppServerNetworkProxyConfig = {
|
||||
enabled?: boolean;
|
||||
profileName?: string;
|
||||
baseProfile?: CodexAppServerNetworkProxyBaseProfile;
|
||||
mode?: CodexAppServerNetworkProxyMode;
|
||||
domains?: Record<string, CodexAppServerNetworkProxyPermission>;
|
||||
unixSockets?: Record<string, CodexAppServerNetworkProxyPermission>;
|
||||
proxyUrl?: string;
|
||||
socksUrl?: string;
|
||||
enableSocks5?: boolean;
|
||||
enableSocks5Udp?: boolean;
|
||||
allowUpstreamProxy?: boolean;
|
||||
allowLocalBinding?: boolean;
|
||||
dangerouslyAllowNonLoopbackProxy?: boolean;
|
||||
dangerouslyAllowAllUnixSockets?: boolean;
|
||||
};
|
||||
|
||||
export type ResolvedCodexAppServerNetworkProxyConfig = {
|
||||
profileName: string;
|
||||
configPatch: JsonObject;
|
||||
};
|
||||
|
||||
export type ResolvedCodexPluginPolicy = {
|
||||
configKey: string;
|
||||
marketplaceName: typeof CODEX_PLUGINS_MARKETPLACE_NAME;
|
||||
@@ -151,6 +177,7 @@ export type CodexAppServerRuntimeOptions = {
|
||||
sandbox: CodexAppServerSandboxMode;
|
||||
approvalsReviewer: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier;
|
||||
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
|
||||
};
|
||||
|
||||
export type CodexModelBackedReviewerContext = {
|
||||
@@ -188,6 +215,7 @@ export type CodexPluginConfig = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
approvalsReviewer?: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
networkProxy?: CodexAppServerNetworkProxyConfig;
|
||||
defaultWorkspaceDir?: string;
|
||||
experimental?: CodexAppServerExperimentalConfig;
|
||||
};
|
||||
@@ -216,6 +244,7 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
|
||||
"sandbox",
|
||||
"approvalsReviewer",
|
||||
"serviceTier",
|
||||
"networkProxy",
|
||||
"defaultWorkspaceDir",
|
||||
"experimental",
|
||||
] as const;
|
||||
@@ -249,6 +278,7 @@ export const CODEX_PLUGIN_ENTRY_CONFIG_KEYS = [
|
||||
const DEFAULT_CODEX_COMPUTER_USE_PLUGIN_NAME = "computer-use";
|
||||
const DEFAULT_CODEX_COMPUTER_USE_MCP_SERVER_NAME = "computer-use";
|
||||
const DEFAULT_CODEX_COMPUTER_USE_MARKETPLACE_DISCOVERY_TIMEOUT_MS = 60_000;
|
||||
const DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE = "openclaw-network";
|
||||
|
||||
const codexAppServerTransportSchema = z.enum(["stdio", "websocket"]);
|
||||
const codexAppServerPolicyModeSchema = z.enum(["yolo", "guardian"]);
|
||||
@@ -273,6 +303,25 @@ const codexAppServerExperimentalSchema = z
|
||||
sandboxExecServer: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
const codexAppServerNetworkProxyPermissionSchema = z.enum(["allow", "deny"]);
|
||||
const codexAppServerNetworkProxySchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
profileName: z.string().trim().min(1).optional(),
|
||||
baseProfile: z.enum(["read-only", "workspace"]).optional(),
|
||||
mode: z.enum(["limited", "full"]).optional(),
|
||||
domains: z.record(z.string(), codexAppServerNetworkProxyPermissionSchema).optional(),
|
||||
unixSockets: z.record(z.string(), codexAppServerNetworkProxyPermissionSchema).optional(),
|
||||
proxyUrl: z.string().trim().min(1).optional(),
|
||||
socksUrl: z.string().trim().min(1).optional(),
|
||||
enableSocks5: z.boolean().optional(),
|
||||
enableSocks5Udp: z.boolean().optional(),
|
||||
allowUpstreamProxy: z.boolean().optional(),
|
||||
allowLocalBinding: z.boolean().optional(),
|
||||
dangerouslyAllowNonLoopbackProxy: z.boolean().optional(),
|
||||
dangerouslyAllowAllUnixSockets: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const codexPluginEntryConfigSchema = z
|
||||
.object({
|
||||
@@ -334,6 +383,7 @@ const codexPluginConfigSchema = z
|
||||
sandbox: codexAppServerSandboxSchema.optional(),
|
||||
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
|
||||
serviceTier: codexAppServerServiceTierSchema,
|
||||
networkProxy: codexAppServerNetworkProxySchema.optional(),
|
||||
defaultWorkspaceDir: z.string().optional(),
|
||||
experimental: codexAppServerExperimentalSchema.optional(),
|
||||
})
|
||||
@@ -549,6 +599,11 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
? normalizedPolicyMode
|
||||
: (explicitPolicyMode ?? normalizedPolicyMode ?? defaultPolicy?.mode ?? "yolo");
|
||||
const serviceTier = normalizeCodexServiceTier(config.serviceTier);
|
||||
const resolvedSandbox =
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access");
|
||||
if (transport === "websocket" && !url) {
|
||||
throw new Error(
|
||||
"plugins.entries.codex.config.appServer.url is required when appServer.transport is websocket",
|
||||
@@ -597,17 +652,14 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
: {}),
|
||||
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
|
||||
approvalPolicySource,
|
||||
sandbox:
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
sandbox: resolvedSandbox,
|
||||
approvalsReviewer:
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -821,6 +873,69 @@ export function codexSandboxPolicyForTurn(
|
||||
};
|
||||
}
|
||||
|
||||
function resolveCodexAppServerNetworkProxy(
|
||||
config: CodexAppServerNetworkProxyConfig | undefined,
|
||||
sandbox: CodexAppServerSandboxMode,
|
||||
): { networkProxy?: ResolvedCodexAppServerNetworkProxyConfig } {
|
||||
if (config?.enabled !== true) {
|
||||
return {};
|
||||
}
|
||||
const profileName =
|
||||
readNonEmptyString(config.profileName) ?? DEFAULT_CODEX_APP_SERVER_NETWORK_PROXY_PROFILE;
|
||||
const fileSystemMode =
|
||||
config.baseProfile === "read-only" || (!config.baseProfile && sandbox === "read-only")
|
||||
? "read"
|
||||
: "write";
|
||||
const networkConfig = removeUndefinedJsonFields({
|
||||
enabled: true,
|
||||
mode: config.mode,
|
||||
domains: normalizeNetworkProxyPermissionMap(config.domains),
|
||||
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
|
||||
proxy_url: readNonEmptyString(config.proxyUrl),
|
||||
socks_url: readNonEmptyString(config.socksUrl),
|
||||
enable_socks5: config.enableSocks5,
|
||||
enable_socks5_udp: config.enableSocks5Udp,
|
||||
allow_upstream_proxy: config.allowUpstreamProxy,
|
||||
allow_local_binding: config.allowLocalBinding,
|
||||
dangerously_allow_non_loopback_proxy: config.dangerouslyAllowNonLoopbackProxy,
|
||||
dangerously_allow_all_unix_sockets: config.dangerouslyAllowAllUnixSockets,
|
||||
});
|
||||
return {
|
||||
networkProxy: {
|
||||
profileName,
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
[profileName]: {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":workspace_roots": {
|
||||
".": fileSystemMode,
|
||||
},
|
||||
},
|
||||
network: networkConfig,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeNetworkProxyPermissionMap(
|
||||
value: Record<string, CodexAppServerNetworkProxyPermission> | undefined,
|
||||
): Record<string, CodexAppServerNetworkProxyPermission> | undefined {
|
||||
const entries = Object.entries(value ?? {})
|
||||
.map(([key, permission]) => [key.trim(), permission] as const)
|
||||
.filter(([key]) => key.length > 0);
|
||||
return entries.length > 0 ? Object.fromEntries(entries) : undefined;
|
||||
}
|
||||
|
||||
function removeUndefinedJsonFields(value: Record<string, JsonValue | undefined>): JsonObject {
|
||||
return Object.fromEntries(
|
||||
Object.entries(value).filter((entry): entry is [string, JsonValue] => entry[1] !== undefined),
|
||||
);
|
||||
}
|
||||
|
||||
export function withMcpElicitationsApprovalPolicy(
|
||||
policy: CodexAppServerEffectiveApprovalPolicy,
|
||||
): CodexAppServerEffectiveApprovalPolicy {
|
||||
|
||||
@@ -76,6 +76,12 @@ export type CodexTurnEnvironmentParams = JsonObject & {
|
||||
cwd: string;
|
||||
};
|
||||
|
||||
export type CodexPermissionProfileSelection = JsonObject & {
|
||||
type: "profile";
|
||||
id: string;
|
||||
modifications?: JsonValue[] | null;
|
||||
};
|
||||
|
||||
export type CodexThreadStartParams = JsonObject & {
|
||||
input?: CodexUserInput[];
|
||||
cwd?: string;
|
||||
@@ -85,6 +91,7 @@ export type CodexThreadStartParams = JsonObject & {
|
||||
approvalPolicy?: string | JsonObject;
|
||||
approvalsReviewer?: string | null;
|
||||
sandbox?: string;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
dynamicTools?: CodexDynamicToolSpec[] | null;
|
||||
developerInstructions?: string;
|
||||
@@ -102,6 +109,7 @@ export type CodexThreadResumeParams = JsonObject & {
|
||||
approvalPolicy?: string | JsonObject;
|
||||
approvalsReviewer?: string | null;
|
||||
sandbox?: string;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
config?: JsonObject;
|
||||
developerInstructions?: string;
|
||||
@@ -153,6 +161,7 @@ export type CodexTurnStartParams = JsonObject & {
|
||||
approvalPolicy?: string | JsonObject;
|
||||
approvalsReviewer?: string | null;
|
||||
sandboxPolicy?: CodexSandboxPolicy;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
effort?: string | null;
|
||||
personality?: string | null;
|
||||
|
||||
@@ -66,6 +66,7 @@ export type CodexAppServerThreadBinding = {
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
networkProxyProfileName?: string;
|
||||
dynamicToolsFingerprint?: string;
|
||||
dynamicToolsContainDeferred?: boolean;
|
||||
webSearchThreadConfigFingerprint?: string;
|
||||
@@ -181,6 +182,10 @@ export async function readCodexAppServerBinding(
|
||||
approvalPolicy: readApprovalPolicy(parsed.approvalPolicy),
|
||||
sandbox: readSandboxMode(parsed.sandbox),
|
||||
serviceTier: readServiceTier(parsed.serviceTier),
|
||||
networkProxyProfileName:
|
||||
typeof parsed.networkProxyProfileName === "string"
|
||||
? parsed.networkProxyProfileName
|
||||
: undefined,
|
||||
dynamicToolsFingerprint:
|
||||
typeof parsed.dynamicToolsFingerprint === "string"
|
||||
? parsed.dynamicToolsFingerprint
|
||||
@@ -256,6 +261,7 @@ export async function writeCodexAppServerBinding(
|
||||
approvalPolicy: binding.approvalPolicy,
|
||||
sandbox: binding.sandbox,
|
||||
serviceTier: binding.serviceTier,
|
||||
networkProxyProfileName: binding.networkProxyProfileName,
|
||||
dynamicToolsFingerprint: binding.dynamicToolsFingerprint,
|
||||
dynamicToolsContainDeferred: binding.dynamicToolsContainDeferred,
|
||||
webSearchThreadConfigFingerprint: binding.webSearchThreadConfigFingerprint,
|
||||
|
||||
@@ -1,162 +0,0 @@
|
||||
// Codex tests cover mirrored session-history branch selection.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { CURRENT_SESSION_VERSION } from "openclaw/plugin-sdk/agent-sessions";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
|
||||
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
await fs.rm(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function writeSession(records: unknown[]): Promise<string> {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-session-history-"));
|
||||
tempDirs.push(dir);
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const header = {
|
||||
type: "session",
|
||||
version: CURRENT_SESSION_VERSION,
|
||||
id: "codex-session",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: dir,
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[header, ...records].map((record) => JSON.stringify(record)).join("\n") + "\n",
|
||||
);
|
||||
return sessionFile;
|
||||
}
|
||||
|
||||
function messageEntry(params: {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
}) {
|
||||
return {
|
||||
type: "message",
|
||||
id: params.id,
|
||||
parentId: params.parentId,
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
message: {
|
||||
role: params.role,
|
||||
content: params.content,
|
||||
timestamp: 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("readCodexMirroredSessionHistoryMessages", () => {
|
||||
it("replays only the branch selected by a leaf control", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
messageEntry({ id: "root", parentId: null, role: "user", content: "root prompt" }),
|
||||
messageEntry({
|
||||
id: "active",
|
||||
parentId: "root",
|
||||
role: "assistant",
|
||||
content: "active answer",
|
||||
}),
|
||||
messageEntry({
|
||||
id: "inactive",
|
||||
parentId: "root",
|
||||
role: "assistant",
|
||||
content: "inactive answer",
|
||||
}),
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive",
|
||||
targetId: "active",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
{ role: "user", content: "root prompt" },
|
||||
{ role: "assistant", content: "active answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("honors explicit navigation to an empty branch", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
messageEntry({ id: "old", parentId: null, role: "user", content: "old prompt" }),
|
||||
{
|
||||
type: "leaf",
|
||||
id: "empty-leaf",
|
||||
parentId: "old",
|
||||
targetId: null,
|
||||
appendParentId: "old",
|
||||
},
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps visible history when continuation rows use a disjoint append cursor", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
messageEntry({ id: "visible", parentId: null, role: "user", content: "visible prompt" }),
|
||||
messageEntry({
|
||||
id: "inactive",
|
||||
parentId: "visible",
|
||||
role: "assistant",
|
||||
content: "inactive answer",
|
||||
}),
|
||||
{
|
||||
type: "metadata",
|
||||
id: "append-metadata",
|
||||
parentId: "inactive",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive",
|
||||
targetId: "visible",
|
||||
appendParentId: "append-metadata",
|
||||
},
|
||||
messageEntry({
|
||||
id: "continued",
|
||||
parentId: "append-metadata",
|
||||
role: "assistant",
|
||||
content: "continued answer",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
{ role: "user", content: "visible prompt" },
|
||||
{ role: "assistant", content: "continued answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps visible history when a continuation references the leaf marker", async () => {
|
||||
const sessionFile = await writeSession([
|
||||
messageEntry({ id: "visible", parentId: null, role: "user", content: "visible prompt" }),
|
||||
messageEntry({
|
||||
id: "inactive",
|
||||
parentId: "visible",
|
||||
role: "assistant",
|
||||
content: "inactive answer",
|
||||
}),
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive",
|
||||
targetId: "visible",
|
||||
},
|
||||
messageEntry({
|
||||
id: "continued",
|
||||
parentId: "active-leaf",
|
||||
role: "assistant",
|
||||
content: "continued answer",
|
||||
}),
|
||||
]);
|
||||
|
||||
await expect(readCodexMirroredSessionHistoryMessages(sessionFile)).resolves.toMatchObject([
|
||||
{ role: "user", content: "visible prompt" },
|
||||
{ role: "assistant", content: "continued answer" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -86,6 +86,36 @@ function createAppServerOptions() {
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createNetworkProxyAppServerOptions() {
|
||||
return {
|
||||
...createAppServerOptions(),
|
||||
networkProxy: {
|
||||
profileName: "mock-proxy",
|
||||
configPatch: {
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
filesystem: {
|
||||
":minimal": "read",
|
||||
":workspace_roots": {
|
||||
".": "write",
|
||||
},
|
||||
},
|
||||
network: {
|
||||
enabled: true,
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
},
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as const;
|
||||
}
|
||||
|
||||
function createThreadLifecycleParams(
|
||||
sessionFile: string,
|
||||
workspaceDir: string,
|
||||
@@ -399,6 +429,53 @@ describe("Codex app-server native code mode config", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a Codex permissions profile for network-proxy thread/start requests", () => {
|
||||
const request = buildThreadStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
cwd: "/repo",
|
||||
dynamicTools: [],
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
});
|
||||
|
||||
expect(request.permissions).toEqual({ type: "profile", id: "mock-proxy" });
|
||||
expect(request).not.toHaveProperty("sandbox");
|
||||
expect(request.config).toMatchObject({
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
network: {
|
||||
enabled: true,
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("uses a Codex permissions profile for network-proxy thread/resume requests", () => {
|
||||
const request = buildThreadResumeParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
developerInstructions: "test instructions",
|
||||
});
|
||||
|
||||
expect(request.permissions).toEqual({ type: "profile", id: "mock-proxy" });
|
||||
expect(request).not.toHaveProperty("sandbox");
|
||||
expect(request.config).toMatchObject({
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"mock-proxy": {
|
||||
network: {
|
||||
domains: {
|
||||
"api.openai.com": "allow",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("disables Codex tool-search features for nano models", () => {
|
||||
const request = buildThreadStartParams(
|
||||
createAttemptParams({ provider: "openai", modelId: "gpt-5.4-nano" }),
|
||||
@@ -617,6 +694,35 @@ describe("Codex app-server turn input image sanitizing", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Codex permissions for network-proxy turn/start requests", () => {
|
||||
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
cwd: "/repo",
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
});
|
||||
|
||||
expect(request).not.toHaveProperty("permissions");
|
||||
expect(request).not.toHaveProperty("sandboxPolicy");
|
||||
});
|
||||
|
||||
it("keeps explicit sandbox policy overrides ahead of network-proxy turn permissions", () => {
|
||||
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
cwd: "/repo",
|
||||
appServer: createNetworkProxyAppServerOptions() as never,
|
||||
sandboxPolicy: {
|
||||
type: "externalSandbox",
|
||||
networkAccess: "enabled",
|
||||
},
|
||||
});
|
||||
|
||||
expect(request).not.toHaveProperty("permissions");
|
||||
expect(request.sandboxPolicy).toEqual({
|
||||
type: "externalSandbox",
|
||||
networkAccess: "enabled",
|
||||
});
|
||||
});
|
||||
|
||||
it("attaches turn-scoped developer instructions without changing thread config", () => {
|
||||
const request = buildTurnStartParams(createAttemptParams({ provider: "openai" }), {
|
||||
threadId: "thread-1",
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexDynamicToolSpec,
|
||||
type CodexPermissionProfileSelection,
|
||||
type CodexSandboxPolicy,
|
||||
type CodexThreadResumeParams,
|
||||
type CodexThreadStartParams,
|
||||
@@ -646,6 +647,7 @@ export async function startOrResumeThread(params: {
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration:
|
||||
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
@@ -694,6 +696,7 @@ export async function startOrResumeThread(params: {
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration:
|
||||
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
@@ -794,6 +797,7 @@ export async function startOrResumeThread(params: {
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
@@ -842,6 +846,7 @@ export async function startOrResumeThread(params: {
|
||||
dynamicToolsContainDeferred,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
@@ -1051,7 +1056,7 @@ export function buildThreadStartParams(
|
||||
cwd: options.cwd,
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
sandbox: options.appServer.sandbox,
|
||||
...codexThreadSandboxOrPermissions(options.appServer),
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
serviceName: "OpenClaw",
|
||||
@@ -1060,6 +1065,7 @@ export function buildThreadStartParams(
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
appServer: options.appServer,
|
||||
}),
|
||||
...resolveCodexThreadEnvironmentSelection(options),
|
||||
developerInstructions:
|
||||
@@ -1109,7 +1115,7 @@ export function buildThreadResumeParams(
|
||||
...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}),
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
sandbox: options.appServer.sandbox,
|
||||
...codexThreadSandboxOrPermissions(options.appServer),
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
|
||||
@@ -1117,6 +1123,7 @@ export function buildThreadResumeParams(
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
appServer: options.appServer,
|
||||
}),
|
||||
developerInstructions:
|
||||
options.developerInstructions ??
|
||||
@@ -1270,6 +1277,7 @@ function buildCodexRuntimeThreadConfigForRun(
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
appServer?: Pick<CodexAppServerRuntimeOptions, "networkProxy">;
|
||||
} = {},
|
||||
): JsonObject {
|
||||
const webSearchConfig = resolveCodexWebSearchPlan({
|
||||
@@ -1286,6 +1294,7 @@ function buildCodexRuntimeThreadConfigForRun(
|
||||
const runtimeConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
baseConfig,
|
||||
options.appServer?.networkProxy?.configPatch,
|
||||
shouldDisableCodexToolSearchForModel(params.modelId)
|
||||
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
|
||||
: undefined,
|
||||
@@ -1326,14 +1335,20 @@ export function buildTurnStartParams(
|
||||
agentDir: params.agentDir,
|
||||
config: params.config,
|
||||
});
|
||||
const useThreadPermissionProfile = options.appServer.networkProxy && !options.sandboxPolicy;
|
||||
return {
|
||||
threadId: options.threadId,
|
||||
input: buildUserInput(params, options.promptText),
|
||||
cwd: options.cwd,
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
sandboxPolicy:
|
||||
options.sandboxPolicy ?? codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
|
||||
...(useThreadPermissionProfile
|
||||
? {}
|
||||
: {
|
||||
sandboxPolicy:
|
||||
options.sandboxPolicy ??
|
||||
codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
|
||||
}),
|
||||
model: modelSelection.model,
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
@@ -1349,6 +1364,20 @@ export function buildTurnStartParams(
|
||||
};
|
||||
}
|
||||
|
||||
function codexThreadSandboxOrPermissions(
|
||||
appServer: Pick<CodexAppServerRuntimeOptions, "networkProxy" | "sandbox">,
|
||||
): Pick<CodexThreadStartParams, "permissions" | "sandbox"> {
|
||||
const permissionProfile = appServer.networkProxy?.profileName;
|
||||
if (permissionProfile) {
|
||||
return { permissions: codexPermissionProfileSelection(permissionProfile) };
|
||||
}
|
||||
return { sandbox: appServer.sandbox };
|
||||
}
|
||||
|
||||
function codexPermissionProfileSelection(profileName: string): CodexPermissionProfileSelection {
|
||||
return { type: "profile", id: profileName };
|
||||
}
|
||||
|
||||
function resolveCodexThreadEnvironmentSelection(options: {
|
||||
nativeCodeModeEnabled?: boolean;
|
||||
environmentSelection?: CodexTurnEnvironmentParams[];
|
||||
|
||||
@@ -180,6 +180,54 @@ describe("codex conversation binding", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses Codex permissions for network-proxy app-server bind threads", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
const requests: Array<{ method: string; params: Record<string, unknown> }> = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
requests.push({ method, params: requestParams });
|
||||
return {
|
||||
thread: { id: "thread-new", sessionId: "session-1", cwd: tempDir },
|
||||
model: "gpt-5.4-mini",
|
||||
};
|
||||
}),
|
||||
});
|
||||
|
||||
await startCodexConversationThread({
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "api.openai.com": "allow" },
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
model: "gpt-5.4-mini",
|
||||
modelProvider: "openai",
|
||||
});
|
||||
|
||||
expect(requests).toHaveLength(1);
|
||||
expect(requests[0]?.method).toBe("thread/start");
|
||||
expect(requests[0]?.params.permissions).toEqual({ type: "profile", id: "openclaw-network" });
|
||||
expect(requests[0]?.params).not.toHaveProperty("sandbox");
|
||||
expect(requests[0]?.params.config).toMatchObject({
|
||||
"features.network_proxy.enabled": true,
|
||||
permissions: {
|
||||
"openclaw-network": {
|
||||
network: {
|
||||
domains: { "api.openai.com": "allow" },
|
||||
allow_upstream_proxy: true,
|
||||
proxy_url: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves Codex auth and omits the public OpenAI provider for native bind threads", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
agentRuntimeMocks.ensureAuthProfileStore.mockReturnValue({
|
||||
@@ -937,7 +985,7 @@ describe("codex conversation binding", () => {
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 1,
|
||||
schemaVersion: 2,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
approvalPolicy: "never",
|
||||
@@ -1126,6 +1174,7 @@ describe("codex conversation binding", () => {
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
networkProxyProfileName: "openclaw-network",
|
||||
}),
|
||||
);
|
||||
let notificationHandler: ((notification: unknown) => void) | undefined;
|
||||
@@ -1203,6 +1252,92 @@ describe("codex conversation binding", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses Codex permissions for network-proxy bound app-server turns", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
networkProxyProfileName: "openclaw-network",
|
||||
}),
|
||||
);
|
||||
let notificationHandler: ((notification: unknown) => void) | undefined;
|
||||
const turnStartParams: Record<string, unknown>[] = [];
|
||||
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
|
||||
request: vi.fn(async (method: string, requestParams: Record<string, unknown>) => {
|
||||
if (method === "turn/start") {
|
||||
turnStartParams.push(requestParams);
|
||||
setImmediate(() =>
|
||||
notificationHandler?.({
|
||||
method: "turn/completed",
|
||||
params: {
|
||||
threadId: "thread-1",
|
||||
turn: {
|
||||
id: "turn-1",
|
||||
status: "completed",
|
||||
items: [{ type: "agentMessage", id: "item-1", text: "done" }],
|
||||
},
|
||||
},
|
||||
}),
|
||||
);
|
||||
return { turn: { id: "turn-1" } };
|
||||
}
|
||||
throw new Error(`unexpected method: ${method}`);
|
||||
}),
|
||||
addNotificationHandler: vi.fn((handler: (notification: unknown) => void) => {
|
||||
notificationHandler = handler;
|
||||
return () => undefined;
|
||||
}),
|
||||
addRequestHandler: vi.fn(() => () => undefined),
|
||||
});
|
||||
|
||||
const result = await handleCodexConversationInboundClaim(
|
||||
{
|
||||
content: "hello",
|
||||
channel: "telegram",
|
||||
isGroup: false,
|
||||
commandAuthorized: true,
|
||||
},
|
||||
{
|
||||
channelId: "telegram",
|
||||
pluginBinding: {
|
||||
bindingId: "binding-1",
|
||||
pluginId: "codex",
|
||||
pluginRoot: tempDir,
|
||||
channel: "telegram",
|
||||
accountId: "default",
|
||||
conversationId: "5185575566",
|
||||
boundAt: Date.now(),
|
||||
data: {
|
||||
kind: "codex-app-server-session",
|
||||
version: 1,
|
||||
sessionFile,
|
||||
workspaceDir: tempDir,
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
pluginConfig: {
|
||||
appServer: {
|
||||
networkProxy: {
|
||||
enabled: true,
|
||||
domains: { "api.openai.com": "allow" },
|
||||
allowUpstreamProxy: true,
|
||||
proxyUrl: "http://127.0.0.1:3128",
|
||||
},
|
||||
},
|
||||
},
|
||||
timeoutMs: 50,
|
||||
},
|
||||
);
|
||||
|
||||
expect(result).toEqual({ handled: true, reply: { text: "done" } });
|
||||
expect(turnStartParams[0]).not.toHaveProperty("permissions");
|
||||
expect(turnStartParams[0]).not.toHaveProperty("sandboxPolicy");
|
||||
});
|
||||
|
||||
it("blocks Guardian-mode bound turns with stale no-approval policy on custom model providers", async () => {
|
||||
const sessionFile = path.join(tempDir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
|
||||
@@ -30,9 +30,11 @@ import {
|
||||
} from "./app-server/config.js";
|
||||
import type {
|
||||
CodexServiceTier,
|
||||
CodexPermissionProfileSelection,
|
||||
CodexThreadResumeResponse,
|
||||
CodexThreadStartResponse,
|
||||
CodexTurnStartResponse,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
} from "./app-server/protocol.js";
|
||||
import {
|
||||
@@ -415,22 +417,43 @@ function buildThreadRequestRuntimeOptions(
|
||||
): {
|
||||
approvalPolicy: ConversationAppServerRuntime["runtime"]["approvalPolicy"];
|
||||
approvalsReviewer: ConversationAppServerRuntime["runtime"]["approvalsReviewer"];
|
||||
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"];
|
||||
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
|
||||
serviceTier?: CodexServiceTier;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
config?: JsonObject;
|
||||
} {
|
||||
const serviceTier = params.serviceTier ?? resolved.runtime.serviceTier;
|
||||
const sandbox = resolved.execPolicy?.touched
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox);
|
||||
return {
|
||||
approvalPolicy: resolved.execPolicy?.touched
|
||||
? resolved.runtime.approvalPolicy
|
||||
: (params.approvalPolicy ?? resolved.runtime.approvalPolicy),
|
||||
approvalsReviewer: resolved.runtime.approvalsReviewer,
|
||||
sandbox: resolved.execPolicy?.touched
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox),
|
||||
...codexConversationSandboxOrPermissions(resolved.runtime, sandbox),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function codexConversationSandboxOrPermissions(
|
||||
runtime: Pick<ConversationAppServerRuntime["runtime"], "networkProxy">,
|
||||
sandbox: ConversationAppServerRuntime["runtime"]["sandbox"],
|
||||
): {
|
||||
sandbox?: ConversationAppServerRuntime["runtime"]["sandbox"];
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
config?: JsonObject;
|
||||
} {
|
||||
const networkProxy = runtime.networkProxy;
|
||||
if (networkProxy) {
|
||||
return {
|
||||
permissions: { type: "profile", id: networkProxy.profileName },
|
||||
config: networkProxy.configPatch,
|
||||
};
|
||||
}
|
||||
return { sandbox };
|
||||
}
|
||||
|
||||
async function writeThreadBindingFromResponse(
|
||||
params: CodexThreadBindingParams,
|
||||
resolved: CodexThreadBindingRuntime,
|
||||
@@ -459,6 +482,7 @@ async function writeThreadBindingFromResponse(
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox),
|
||||
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier,
|
||||
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
|
||||
},
|
||||
{
|
||||
...resolved.agentLookup,
|
||||
@@ -568,6 +592,9 @@ async function runBoundTurn(params: {
|
||||
const sandbox = useModelScopedPolicy
|
||||
? modelScopedRuntime.sandbox
|
||||
: (binding.sandbox ?? modelScopedRuntime.sandbox);
|
||||
const permissionProfile = modelScopedRuntime.networkProxy?.profileName;
|
||||
const useStickyNetworkProfile =
|
||||
permissionProfile !== undefined && binding.networkProxyProfileName === permissionProfile;
|
||||
assertNativeConversationApprovalPolicySupported({
|
||||
execPolicy,
|
||||
approvalPolicy,
|
||||
@@ -641,7 +668,9 @@ async function runBoundTurn(params: {
|
||||
cwd: workspaceDir,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
|
||||
sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir),
|
||||
...(useStickyNetworkProfile
|
||||
? {}
|
||||
: { sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir) }),
|
||||
...(modelSelection?.model ? { model: modelSelection.model } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...((binding.serviceTier ?? runtime.serviceTier)
|
||||
|
||||
@@ -436,32 +436,18 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
}
|
||||
|
||||
if (action === "search") {
|
||||
const guildId = readStringParam(actionParams, "guildId");
|
||||
const query =
|
||||
readStringParam(actionParams, "query") ?? readStringParam(actionParams, "content");
|
||||
if (!query) {
|
||||
throw new Error("Discord search requires query text. Provide query or content.");
|
||||
}
|
||||
// Fall back to the current session channel when no explicit channelId,
|
||||
// channelIds, or guildId is provided. This lets the runtime resolve
|
||||
// guildId from the channel without broadening explicitly-filtered or
|
||||
// explicitly guild-scoped searches.
|
||||
const explicitChannelIds = readStringArrayParam(actionParams, "channelIds");
|
||||
const channelId =
|
||||
readStringParam(actionParams, "channelId") ??
|
||||
(!guildId &&
|
||||
!explicitChannelIds?.length &&
|
||||
ctx.toolContext?.currentChannelProvider?.trim().toLowerCase() === "discord"
|
||||
? ctx.toolContext?.currentChannelId?.trim() || undefined
|
||||
: undefined);
|
||||
const guildId = readStringParam(actionParams, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const query = readStringParam(actionParams, "query", { required: true });
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "searchMessages",
|
||||
accountId: accountId ?? undefined,
|
||||
...(guildId ? { guildId } : {}),
|
||||
guildId,
|
||||
content: query,
|
||||
channelId,
|
||||
channelIds: explicitChannelIds,
|
||||
channelId: readStringParam(actionParams, "channelId"),
|
||||
channelIds: readStringArrayParam(actionParams, "channelIds"),
|
||||
authorId: readStringParam(actionParams, "authorId"),
|
||||
authorIds: readStringArrayParam(actionParams, "authorIds"),
|
||||
limit: readPositiveIntegerParam(actionParams, "limit"),
|
||||
|
||||
@@ -614,59 +614,4 @@ describe("handleDiscordMessageAction", () => {
|
||||
|
||||
expect(handleDiscordActionMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("does not add session channel to search when explicit channelIds are provided", async () => {
|
||||
handleDiscordActionMock.mockResolvedValueOnce({ content: [], details: { ok: true } });
|
||||
await handleDiscordMessageAction({
|
||||
action: "search",
|
||||
params: {
|
||||
query: "test query",
|
||||
channelIds: ["ch-1", "ch-2"],
|
||||
guildId: "g1",
|
||||
},
|
||||
cfg: discordConfig(),
|
||||
toolContext: {
|
||||
currentChannelProvider: "discord",
|
||||
currentChannelId: "session-ch",
|
||||
},
|
||||
});
|
||||
|
||||
expect(handleDiscordActionMock).toHaveBeenCalledTimes(1);
|
||||
const payload = handleDiscordActionMock.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({
|
||||
action: "searchMessages",
|
||||
content: "test query",
|
||||
guildId: "g1",
|
||||
channelIds: ["ch-1", "ch-2"],
|
||||
});
|
||||
// Session channel must NOT appear as channelId when explicit channelIds exist.
|
||||
expect(payload.channelId).toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not inject session channel when guildId is explicit and no channel filters are provided", async () => {
|
||||
handleDiscordActionMock.mockResolvedValueOnce({ content: [], details: { ok: true } });
|
||||
await handleDiscordMessageAction({
|
||||
action: "search",
|
||||
params: {
|
||||
query: "guild-wide query",
|
||||
guildId: "g1",
|
||||
},
|
||||
cfg: discordConfig(),
|
||||
toolContext: {
|
||||
currentChannelProvider: "discord",
|
||||
currentChannelId: "session-ch",
|
||||
},
|
||||
});
|
||||
|
||||
expect(handleDiscordActionMock).toHaveBeenCalledTimes(1);
|
||||
const payload = handleDiscordActionMock.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({
|
||||
action: "searchMessages",
|
||||
content: "guild-wide query",
|
||||
guildId: "g1",
|
||||
});
|
||||
// Guild-wide search must NOT be narrowed to the session channel.
|
||||
expect(payload.channelId).toBeUndefined();
|
||||
expect(payload.channelIds).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -184,51 +184,18 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
|
||||
if (!ctx.isActionEnabled("search")) {
|
||||
throw new Error("Discord search is disabled.");
|
||||
}
|
||||
let guildId = readStringParam(ctx.params, "guildId");
|
||||
const content =
|
||||
readStringParam(ctx.params, "content") ?? readStringParam(ctx.params, "query");
|
||||
if (!content) {
|
||||
throw new Error("Discord search requires content or query text.");
|
||||
}
|
||||
const guildId = readStringParam(ctx.params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(ctx.params, "content", {
|
||||
required: true,
|
||||
});
|
||||
const channelId = readStringParam(ctx.params, "channelId");
|
||||
const channelIds = readStringArrayParam(ctx.params, "channelIds");
|
||||
// Resolve guildId from channel info when not explicitly provided.
|
||||
if (!guildId) {
|
||||
const rawInferChannelId = channelId ?? channelIds?.[0];
|
||||
if (rawInferChannelId) {
|
||||
try {
|
||||
const inferChannelId =
|
||||
discordMessagingActionRuntime.resolveDiscordChannelId(rawInferChannelId);
|
||||
const channelInfo = await discordMessagingActionRuntime.fetchChannelInfoDiscord(
|
||||
inferChannelId,
|
||||
ctx.withOpts(),
|
||||
);
|
||||
if (channelInfo && typeof channelInfo === "object") {
|
||||
const record = channelInfo as unknown as Record<string, unknown>;
|
||||
const resolved = record.guild_id ?? record.guildId;
|
||||
if (typeof resolved === "string" && resolved.trim()) {
|
||||
guildId = resolved.trim();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Channel info fetch failed; fall through to descriptive error.
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!guildId) {
|
||||
throw new Error(
|
||||
"Discord search requires guildId. Provide guildId explicitly, or provide channelId so the guild can be resolved from the channel.",
|
||||
);
|
||||
}
|
||||
const authorId = readStringParam(ctx.params, "authorId");
|
||||
const authorIds = readStringArrayParam(ctx.params, "authorIds");
|
||||
const limit = readPositiveIntegerParam(ctx.params, "limit");
|
||||
const channelIdList = [
|
||||
...(channelIds ?? []).map((id) =>
|
||||
discordMessagingActionRuntime.resolveDiscordChannelId(id),
|
||||
),
|
||||
...(channelId ? [discordMessagingActionRuntime.resolveDiscordChannelId(channelId)] : []),
|
||||
];
|
||||
const channelIdList = [...(channelIds ?? []), ...(channelId ? [channelId] : [])];
|
||||
if (channelIdList.length > 0) {
|
||||
for (const targetChannelId of channelIdList) {
|
||||
await ctx.assertReadTargetAllowed({ guildId, channelId: targetChannelId });
|
||||
|
||||
@@ -1139,72 +1139,6 @@ describe("handleDiscordMessagingAction", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves guildId from channel info when guildId is omitted in searchMessages", async () => {
|
||||
fetchChannelInfoDiscord.mockResolvedValueOnce({
|
||||
id: "C1",
|
||||
type: 0,
|
||||
guild_id: "resolved-guild",
|
||||
});
|
||||
searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] });
|
||||
|
||||
await handleMessagingAction(
|
||||
"searchMessages",
|
||||
{ channelId: "C1", content: "hello" },
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", expect.anything());
|
||||
expect(searchMessagesDiscord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guildId: "resolved-guild", content: "hello" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes channel: prefixed channelId before resolving guildId in searchMessages", async () => {
|
||||
fetchChannelInfoDiscord.mockResolvedValueOnce({
|
||||
id: "C1",
|
||||
type: 0,
|
||||
guild_id: "resolved-guild",
|
||||
});
|
||||
searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] });
|
||||
|
||||
await handleMessagingAction(
|
||||
"searchMessages",
|
||||
{ channelId: "channel:C1", content: "hello" },
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(fetchChannelInfoDiscord).toHaveBeenCalledWith("C1", expect.anything());
|
||||
expect(searchMessagesDiscord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guildId: "resolved-guild", content: "hello", channelIds: ["C1"] }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts query as alias for content in searchMessages", async () => {
|
||||
searchMessagesDiscord.mockResolvedValueOnce({ total_results: 0, messages: [] });
|
||||
|
||||
await handleMessagingAction(
|
||||
"searchMessages",
|
||||
{ guildId: "G1", query: "find this" },
|
||||
enableAllActions,
|
||||
);
|
||||
|
||||
expect(searchMessagesDiscord).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ guildId: "G1", content: "find this" }),
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
|
||||
it("throws descriptive error when guildId cannot be resolved in searchMessages", async () => {
|
||||
await expect(
|
||||
handleMessagingAction("searchMessages", { content: "hello" }, enableAllActions),
|
||||
).rejects.toThrow(
|
||||
"Discord search requires guildId. Provide guildId explicitly, or provide channelId so the guild can be resolved from the channel.",
|
||||
);
|
||||
expect(searchMessagesDiscord).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends voice messages from a local file path", async () => {
|
||||
sendVoiceMessageDiscord.mockClear();
|
||||
sendMessageDiscord.mockClear();
|
||||
|
||||
@@ -852,39 +852,6 @@ describe("processDiscordMessage ack reactions", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("records accepted mention ingress before acking and dispatching", async () => {
|
||||
const events: string[] = [];
|
||||
recordInboundSession.mockImplementationOnce(async () => {
|
||||
events.push("record");
|
||||
});
|
||||
sendMocks.reactMessageDiscord.mockImplementationOnce(async () => {
|
||||
events.push("ack");
|
||||
});
|
||||
dispatchInboundMessage.mockImplementationOnce(async () => {
|
||||
events.push("dispatch");
|
||||
return createNoQueuedDispatchResult();
|
||||
});
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
accountId: "ops",
|
||||
shouldRequireMention: true,
|
||||
effectiveWasMentioned: true,
|
||||
route: {
|
||||
agentId: "main",
|
||||
channel: "discord",
|
||||
accountId: "ops",
|
||||
sessionKey: "agent:main:discord:channel:c1",
|
||||
mainSessionKey: "agent:main:main",
|
||||
},
|
||||
});
|
||||
|
||||
await runProcessDiscordMessage(ctx);
|
||||
|
||||
expect(events).toEqual(["record", "ack", "dispatch"]);
|
||||
expect(recordInboundSession).toHaveBeenCalledTimes(1);
|
||||
expect(sendMocks.reactMessageDiscord).toHaveBeenCalled();
|
||||
expect(dispatchInboundMessage).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("uses preflight-resolved messageChannelId when message.channelId is missing", async () => {
|
||||
const ctx = await createAutomaticSourceDeliveryContext({
|
||||
message: {
|
||||
|
||||
@@ -415,24 +415,14 @@ async function processDiscordMessageInner(
|
||||
statusReactionsActive = true;
|
||||
void statusReactions.setQueued();
|
||||
};
|
||||
let initialAckReactionQueued = false;
|
||||
const queueInitialAckReactionAfterRecord = () => {
|
||||
if (initialAckReactionQueued) {
|
||||
return;
|
||||
}
|
||||
initialAckReactionQueued = true;
|
||||
if (statusReactionsEnabled) {
|
||||
statusReactionsActive = true;
|
||||
}
|
||||
queueInitialDiscordAckReaction({
|
||||
enabled: statusReactionsEnabled,
|
||||
shouldSendAckReaction,
|
||||
ackReaction,
|
||||
statusReactions,
|
||||
reactionAdapter: discordAdapter,
|
||||
target: `${messageChannelId}/${message.id}`,
|
||||
});
|
||||
};
|
||||
queueInitialDiscordAckReaction({
|
||||
enabled: statusReactionsEnabled,
|
||||
shouldSendAckReaction,
|
||||
ackReaction,
|
||||
statusReactions,
|
||||
reactionAdapter: discordAdapter,
|
||||
target: `${messageChannelId}/${message.id}`,
|
||||
});
|
||||
const processContext = await buildDiscordMessageProcessContext({
|
||||
ctx,
|
||||
text,
|
||||
@@ -963,7 +953,6 @@ async function processDiscordMessageInner(
|
||||
storePath: turn.storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
afterRecord: queueInitialAckReactionAfterRecord,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
|
||||
@@ -26,12 +26,6 @@ const mockMessage = {
|
||||
timestamp: "123",
|
||||
} as unknown as Parameters<MaybeCreateDiscordAutoThreadFn>[0]["message"];
|
||||
|
||||
function createMockMessage(overrides: Record<string, unknown>) {
|
||||
return Object.assign({}, mockMessage, overrides) as Parameters<
|
||||
MaybeCreateDiscordAutoThreadFn
|
||||
>[0]["message"];
|
||||
}
|
||||
|
||||
function createBaseParams(
|
||||
overrides: Partial<Parameters<MaybeCreateDiscordAutoThreadFn>[0]> = {},
|
||||
): Parameters<MaybeCreateDiscordAutoThreadFn>[0] {
|
||||
@@ -132,63 +126,15 @@ describe("maybeCreateDiscordAutoThread", () => {
|
||||
|
||||
it("creates auto-thread if channelType is GuildText", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
const result = await maybeCreateDiscordAutoThread(createBaseParams());
|
||||
expect(result).toBe("thread1");
|
||||
expect(postMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses an existing message thread before creating a new one", async () => {
|
||||
getMock.mockResolvedValueOnce({ thread: { id: "existing-thread" } });
|
||||
const result = await maybeCreateDiscordAutoThread(createBaseParams());
|
||||
|
||||
expect(result).toBe("existing-thread");
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("reuses an existing message thread before skipping bot-authored messages", async () => {
|
||||
getMock.mockResolvedValueOnce({ thread: { id: "existing-thread" } });
|
||||
const result = await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
message: createMockMessage({
|
||||
author: { bot: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBe("existing-thread");
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("skips creating new auto-threads for bot-authored messages", async () => {
|
||||
getMock.mockResolvedValueOnce({});
|
||||
const result = await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
message: createMockMessage({
|
||||
author: { bot: true },
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result).toBeUndefined();
|
||||
expect(postMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still creates an auto-thread when the existing-thread lookup fails", async () => {
|
||||
getMock.mockRejectedValueOnce(new Error("transient fetch failure"));
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
|
||||
const result = await maybeCreateDiscordAutoThread(createBaseParams());
|
||||
|
||||
expect(result).toBe("thread1");
|
||||
expect(postMock).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
||||
it("uses configured autoArchiveDuration", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: "10080" },
|
||||
@@ -199,7 +145,6 @@ describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
||||
|
||||
it("accepts numeric autoArchiveDuration", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: 4320 },
|
||||
@@ -210,7 +155,6 @@ describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
||||
|
||||
it("defaults to 60 when autoArchiveDuration not set", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(createBaseParams());
|
||||
expectRestBodyField(postMock, "auto_archive_duration", 60);
|
||||
});
|
||||
@@ -219,7 +163,6 @@ describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
||||
describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
it("renames created thread when generated mode is enabled", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
patchMock.mockResolvedValueOnce({});
|
||||
generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");
|
||||
|
||||
@@ -250,7 +193,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("does not block thread creation while title summary is pending", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
patchMock.mockResolvedValueOnce({});
|
||||
let resolveTitle: ((value: string | null) => void) | undefined;
|
||||
generateThreadTitleMock.mockReturnValueOnce(
|
||||
@@ -277,7 +219,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("uses channel-specific thread override for generated title model", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
patchMock.mockResolvedValueOnce({});
|
||||
generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");
|
||||
|
||||
@@ -307,7 +248,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("falls back to parent channel override for generated title model", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
patchMock.mockResolvedValueOnce({});
|
||||
generateThreadTitleMock.mockResolvedValueOnce("Deploy rollout summary");
|
||||
|
||||
@@ -337,7 +277,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("skips summarization when cfg or agentId is missing", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
channelConfig: { allowed: true, autoThread: true, autoThreadName: "generated" },
|
||||
@@ -350,7 +289,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("does not rename when autoThreadName is not set", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
channelConfig: { allowed: true, autoThread: true },
|
||||
@@ -363,7 +301,6 @@ describe("maybeCreateDiscordAutoThread autoThreadName", () => {
|
||||
|
||||
it("does not rename when generated title sanitizes to fallback thread name", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
generateThreadTitleMock.mockResolvedValueOnce("<@123456789012345678> <#987654321098765432>");
|
||||
|
||||
const cfg = { agents: { defaults: { model: "anthropic/claude-opus-4-6" } } } as OpenClawConfig;
|
||||
|
||||
@@ -147,28 +147,6 @@ export async function maybeCreateDiscordAutoThread(
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
try {
|
||||
const existingThreadId = (
|
||||
(await getChannelMessage(params.client.rest, messageChannelId, params.message.id)) as {
|
||||
thread?: { id?: string };
|
||||
}
|
||||
)?.thread?.id;
|
||||
if (existingThreadId) {
|
||||
logVerbose(
|
||||
`discord: autoThread reusing existing thread ${existingThreadId} on ${messageChannelId}/${params.message.id}`,
|
||||
);
|
||||
return existingThreadId;
|
||||
}
|
||||
} catch {
|
||||
// Best effort only. A failed message refetch must not block creating the thread.
|
||||
}
|
||||
if (params.message.author?.bot) {
|
||||
logVerbose(
|
||||
`discord: autoThread skipped for bot-authored message ${messageChannelId}/${params.message.id}`,
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rawThreadSource = params.baseText || params.combinedBody || "Thread";
|
||||
const threadName = sanitizeDiscordThreadName(rawThreadSource, params.message.id);
|
||||
const archiveDuration = params.channelConfig?.autoArchiveDuration
|
||||
|
||||
@@ -8,7 +8,6 @@ import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import { parseMergeForwardContent } from "./bot-content.js";
|
||||
import type { FeishuMessageEvent } from "./bot.js";
|
||||
import { handleFeishuMessage } from "./bot.js";
|
||||
import { resolveFeishuMessageDedupeKey } from "./dedupe-key.js";
|
||||
import { createFeishuMessageReceiveHandler } from "./monitor.message-handler.js";
|
||||
import { setFeishuRuntime } from "./runtime.js";
|
||||
|
||||
@@ -4167,70 +4166,6 @@ describe("handleFeishuMessage command authorization", () => {
|
||||
});
|
||||
|
||||
describe("createFeishuMessageReceiveHandler media dedupe", () => {
|
||||
it("preserves the original dispatch dedupe key when debounce merges text content", async () => {
|
||||
const handleMessage = vi.fn(async () => undefined);
|
||||
const core = {
|
||||
channel: {
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: vi.fn(() => 10),
|
||||
createInboundDebouncer: vi.fn(
|
||||
(options: { onFlush: (entries: FeishuMessageEvent[]) => Promise<void> | void }) => {
|
||||
const entries: FeishuMessageEvent[] = [];
|
||||
return {
|
||||
enqueue: async (event: FeishuMessageEvent) => {
|
||||
entries.push(event);
|
||||
if (entries.length === 2) {
|
||||
await options.onFlush(entries);
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
),
|
||||
},
|
||||
commands: {
|
||||
isControlCommandMessage: vi.fn(() => false),
|
||||
},
|
||||
},
|
||||
} as unknown as PluginRuntime;
|
||||
const createTextEvent = (messageId: string, createTime: string, text: string) =>
|
||||
({
|
||||
sender: { sender_id: { open_id: "ou-text-debounce" } },
|
||||
message: {
|
||||
message_id: messageId,
|
||||
chat_id: "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text }),
|
||||
create_time: createTime,
|
||||
},
|
||||
}) satisfies FeishuMessageEvent;
|
||||
const last = createTextEvent("msg-text-last", "1710000001000", "second");
|
||||
const handler = createFeishuMessageReceiveHandler({
|
||||
cfg: { channels: { feishu: { dmPolicy: "open" } } } as ClawdbotConfig,
|
||||
channelRuntime: core.channel,
|
||||
accountId: "receive-text-debounce",
|
||||
chatHistories: new Map(),
|
||||
handleMessage,
|
||||
resolveDebounceText: ({ event }) =>
|
||||
(JSON.parse(event.message.content) as { text: string }).text,
|
||||
hasProcessedMessage: vi.fn(async () => false),
|
||||
recordProcessedMessage: vi.fn(async () => true),
|
||||
});
|
||||
|
||||
await handler(createTextEvent("msg-text-first", "1710000000000", "first"));
|
||||
await handler(last);
|
||||
|
||||
const call = mockCallArg<{
|
||||
event?: FeishuMessageEvent;
|
||||
messageDedupeKey?: string;
|
||||
}>(handleMessage, 0, 0);
|
||||
expect(call.event?.message.content).toBe(JSON.stringify({ text: "first\nsecond" }));
|
||||
expect(call.messageDedupeKey).toBe(resolveFeishuMessageDedupeKey(last));
|
||||
expect(resolveFeishuMessageDedupeKey(call.event as FeishuMessageEvent)).not.toBe(
|
||||
call.messageDedupeKey,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps same-id media variants distinct at receive time", async () => {
|
||||
const handleMessage = vi.fn(async () => undefined);
|
||||
const core = {
|
||||
|
||||
@@ -466,7 +466,6 @@ export async function handleFeishuMessage(params: {
|
||||
chatHistories?: Map<string, HistoryEntry[]>;
|
||||
accountId?: string;
|
||||
processingClaimHeld?: boolean;
|
||||
messageDedupeKey?: string;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
cfg,
|
||||
@@ -478,7 +477,6 @@ export async function handleFeishuMessage(params: {
|
||||
chatHistories,
|
||||
accountId,
|
||||
processingClaimHeld = false,
|
||||
messageDedupeKey: messageDedupeKeyOverride,
|
||||
} = params;
|
||||
|
||||
// Resolve account with merged config
|
||||
@@ -489,7 +487,7 @@ export async function handleFeishuMessage(params: {
|
||||
const error = runtime?.error ?? console.error;
|
||||
|
||||
const messageId = event.message.message_id;
|
||||
const messageDedupeKey = messageDedupeKeyOverride ?? resolveFeishuMessageDedupeKey(event);
|
||||
const messageDedupeKey = resolveFeishuMessageDedupeKey(event);
|
||||
if (
|
||||
!(await finalizeFeishuMessageProcessing({
|
||||
messageId: messageDedupeKey,
|
||||
|
||||
@@ -1,90 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveFeishuMessageDedupeKey } from "./dedupe-key.js";
|
||||
import type { FeishuMessageEvent } from "./event-types.js";
|
||||
|
||||
function textEvent(overrides: {
|
||||
messageId: string;
|
||||
createTime?: string;
|
||||
senderOpenId?: string;
|
||||
chatId?: string;
|
||||
text?: string;
|
||||
}): FeishuMessageEvent {
|
||||
return {
|
||||
sender: { sender_id: { open_id: overrides.senderOpenId ?? "ou-user" } },
|
||||
message: {
|
||||
message_id: overrides.messageId,
|
||||
chat_id: overrides.chatId ?? "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: overrides.text ?? "hello" }),
|
||||
create_time: overrides.createTime,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
describe("resolveFeishuMessageDedupeKey", () => {
|
||||
it("collapses redelivered text with a fresh message_id but identical sender/chat/create_time/content (#46778)", () => {
|
||||
const first = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_first", createTime: "1710000000000" }),
|
||||
);
|
||||
const retry = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_second", createTime: "1710000000000" }),
|
||||
);
|
||||
expect(first).toBeDefined();
|
||||
expect(retry).toBe(first);
|
||||
});
|
||||
|
||||
it("keeps genuine repeat sends distinct via create_time", () => {
|
||||
const a = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_a", createTime: "1710000000000" }),
|
||||
);
|
||||
const b = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_b", createTime: "1710000001000" }),
|
||||
);
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
|
||||
it("does not collide across senders, chats, or content", () => {
|
||||
const base = textEvent({ messageId: "om_1", createTime: "1710000000000" });
|
||||
const otherSender = textEvent({
|
||||
messageId: "om_2",
|
||||
createTime: "1710000000000",
|
||||
senderOpenId: "ou-other",
|
||||
});
|
||||
const otherChat = textEvent({ messageId: "om_3", createTime: "1710000000000", chatId: "oc-2" });
|
||||
const otherText = textEvent({ messageId: "om_4", createTime: "1710000000000", text: "bye" });
|
||||
const baseKey = resolveFeishuMessageDedupeKey(base);
|
||||
expect(resolveFeishuMessageDedupeKey(otherSender)).not.toBe(baseKey);
|
||||
expect(resolveFeishuMessageDedupeKey(otherChat)).not.toBe(baseKey);
|
||||
expect(resolveFeishuMessageDedupeKey(otherText)).not.toBe(baseKey);
|
||||
});
|
||||
|
||||
it("falls back to message_id for text without a stable retry anchor", () => {
|
||||
const key = resolveFeishuMessageDedupeKey(textEvent({ messageId: "om_no_time" }));
|
||||
expect(key).toBe("om_no_time");
|
||||
});
|
||||
|
||||
it("falls back to message_id for malformed create_time", () => {
|
||||
const key = resolveFeishuMessageDedupeKey(
|
||||
textEvent({ messageId: "om_bad_time", createTime: "1710000000000ms" }),
|
||||
);
|
||||
expect(key).toBe("om_bad_time");
|
||||
});
|
||||
|
||||
it("keeps media keyed by message_id plus media key", () => {
|
||||
const event: FeishuMessageEvent = {
|
||||
sender: { sender_id: { open_id: "ou-user" } },
|
||||
message: {
|
||||
message_id: "om_media",
|
||||
chat_id: "oc-dm",
|
||||
chat_type: "p2p",
|
||||
message_type: "image",
|
||||
content: JSON.stringify({ image_key: "img_123" }),
|
||||
create_time: "1710000000000",
|
||||
},
|
||||
};
|
||||
expect(resolveFeishuMessageDedupeKey(event)).toBe(
|
||||
JSON.stringify(["om_media", "image_key:img_123"]),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1,12 +1,10 @@
|
||||
// Feishu plugin module implements dedupe key behavior.
|
||||
import { createHash } from "node:crypto";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { asNullableRecord as readRecord } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { FeishuMessageEvent } from "./event-types.js";
|
||||
import { normalizeFeishuExternalKey } from "./external-keys.js";
|
||||
import { parsePostContent } from "./post.js";
|
||||
|
||||
type FeishuMessageDedupeInput = Pick<FeishuMessageEvent, "message" | "sender">;
|
||||
type FeishuMessageDedupeInput = Pick<FeishuMessageEvent, "message">;
|
||||
|
||||
function readExternalKey(value: unknown): string | undefined {
|
||||
return normalizeFeishuExternalKey(typeof value === "string" ? value : "");
|
||||
@@ -59,42 +57,6 @@ function resolveMessageMediaParts(messageType: string, content: string): string[
|
||||
}
|
||||
}
|
||||
|
||||
function resolveSenderIdentity(event: FeishuMessageDedupeInput): string | undefined {
|
||||
const senderId = event.sender?.sender_id;
|
||||
return (
|
||||
senderId?.open_id?.trim() ||
|
||||
senderId?.union_id?.trim() ||
|
||||
senderId?.user_id?.trim() ||
|
||||
undefined
|
||||
);
|
||||
}
|
||||
|
||||
// Feishu can redeliver the same logical text message with a fresh message_id
|
||||
// (retry/reconnect), defeating message_id-based dedupe (#46778). For text we key
|
||||
// on a stable retry identity instead: same sender + chat + create_time + content
|
||||
// is the same logical message. create_time is the message's own server timestamp
|
||||
// and stays fixed across redeliveries, so genuine repeat sends (which get a new
|
||||
// create_time) keep distinct keys and are never suppressed. Falls back to
|
||||
// message_id when any field is missing so behavior is unchanged then.
|
||||
function resolveTextRetryDedupeKey(event: FeishuMessageDedupeInput): string | undefined {
|
||||
const createTime = event.message.create_time?.trim();
|
||||
const chatId = event.message.chat_id?.trim();
|
||||
const senderId = resolveSenderIdentity(event);
|
||||
if (
|
||||
!createTime ||
|
||||
parseStrictNonNegativeInteger(createTime) === undefined ||
|
||||
!chatId ||
|
||||
!senderId
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
const contentHash = createHash("sha256")
|
||||
.update(event.message.content, "utf8")
|
||||
.digest("hex")
|
||||
.slice(0, 32);
|
||||
return JSON.stringify(["text-retry", senderId, chatId, createTime, contentHash]);
|
||||
}
|
||||
|
||||
export function resolveFeishuMessageDedupeKey(event: FeishuMessageDedupeInput): string | undefined {
|
||||
const messageId = event.message.message_id?.trim();
|
||||
if (!messageId) {
|
||||
@@ -102,11 +64,5 @@ export function resolveFeishuMessageDedupeKey(event: FeishuMessageDedupeInput):
|
||||
}
|
||||
const messageType = event.message.message_type.trim();
|
||||
const mediaParts = resolveMessageMediaParts(messageType, event.message.content);
|
||||
if (mediaParts.length > 0) {
|
||||
return buildMediaDedupeKey(messageId, mediaParts);
|
||||
}
|
||||
if (messageType === "text") {
|
||||
return resolveTextRetryDedupeKey(event) ?? messageId;
|
||||
}
|
||||
return messageId;
|
||||
return mediaParts.length > 0 ? buildMediaDedupeKey(messageId, mediaParts) : messageId;
|
||||
}
|
||||
|
||||
@@ -1,112 +0,0 @@
|
||||
// Feishu tests cover monitor.message handler plugin behavior.
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { ClawdbotConfig, PluginRuntime } from "../runtime-api.js";
|
||||
import type { FeishuMessageEvent } from "./event-types.js";
|
||||
import { createFeishuMessageReceiveHandler } from "./monitor.message-handler.js";
|
||||
|
||||
type MessageReceiveHandlerContext = Parameters<typeof createFeishuMessageReceiveHandler>[0];
|
||||
type HandleMessageParams = Parameters<MessageReceiveHandlerContext["handleMessage"]>[0];
|
||||
|
||||
function createTextEvent(params: {
|
||||
messageId: string;
|
||||
senderOpenId: string;
|
||||
senderType: "bot" | "user";
|
||||
}): FeishuMessageEvent {
|
||||
return {
|
||||
sender: {
|
||||
sender_id: { open_id: params.senderOpenId },
|
||||
sender_type: params.senderType,
|
||||
},
|
||||
message: {
|
||||
message_id: params.messageId,
|
||||
chat_id: "oc_chat_1",
|
||||
chat_type: "p2p",
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: "hello" }),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createHandler() {
|
||||
let onFlush: ((entries: FeishuMessageEvent[]) => Promise<void>) | undefined;
|
||||
const enqueue = vi.fn(async (event: FeishuMessageEvent) => {
|
||||
await onFlush?.([event]);
|
||||
});
|
||||
const channelRuntime = {
|
||||
commands: {
|
||||
isControlCommandMessage: () => false,
|
||||
},
|
||||
debounce: {
|
||||
resolveInboundDebounceMs: () => 0,
|
||||
createInboundDebouncer: vi.fn((params: { onFlush: typeof onFlush }) => {
|
||||
onFlush = params.onFlush;
|
||||
return { enqueue };
|
||||
}),
|
||||
},
|
||||
} as unknown as PluginRuntime["channel"];
|
||||
const handleMessage = vi.fn(async (_params: HandleMessageParams) => {});
|
||||
|
||||
const handler = createFeishuMessageReceiveHandler({
|
||||
cfg: {} as ClawdbotConfig,
|
||||
channelRuntime,
|
||||
accountId: "default",
|
||||
chatHistories: new Map(),
|
||||
handleMessage,
|
||||
resolveDebounceText: () => "hello",
|
||||
hasProcessedMessage: vi.fn(async () => false),
|
||||
recordProcessedMessage: vi.fn(async () => true),
|
||||
getBotOpenId: () => "ou_bot",
|
||||
});
|
||||
|
||||
return { handler, handleMessage, enqueue };
|
||||
}
|
||||
|
||||
describe("createFeishuMessageReceiveHandler self-message filtering", () => {
|
||||
it("drops the current bot before debounce and processing claims", async () => {
|
||||
const { handler, handleMessage, enqueue } = createHandler();
|
||||
|
||||
await handler(
|
||||
createTextEvent({
|
||||
messageId: "om_reused",
|
||||
senderOpenId: "ou_bot",
|
||||
senderType: "bot",
|
||||
}),
|
||||
);
|
||||
await handler(
|
||||
createTextEvent({
|
||||
messageId: "om_reused",
|
||||
senderOpenId: "ou_user",
|
||||
senderType: "user",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(enqueue).toHaveBeenCalledTimes(1);
|
||||
expect(handleMessage).toHaveBeenCalledTimes(1);
|
||||
expect(handleMessage.mock.calls[0]?.[0]?.event.sender.sender_id.open_id).toBe("ou_user");
|
||||
});
|
||||
|
||||
it("keeps peer bot and user messages flowing to dispatch", async () => {
|
||||
const { handler, handleMessage, enqueue } = createHandler();
|
||||
|
||||
await handler(
|
||||
createTextEvent({
|
||||
messageId: "om_other_bot",
|
||||
senderOpenId: "ou_other_bot",
|
||||
senderType: "bot",
|
||||
}),
|
||||
);
|
||||
await handler(
|
||||
createTextEvent({
|
||||
messageId: "om_user",
|
||||
senderOpenId: "ou_user",
|
||||
senderType: "user",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(enqueue).toHaveBeenCalledTimes(2);
|
||||
expect(handleMessage).toHaveBeenCalledTimes(2);
|
||||
expect(
|
||||
handleMessage.mock.calls.map(([params]) => params.event.sender.sender_id.open_id),
|
||||
).toEqual(["ou_other_bot", "ou_user"]);
|
||||
});
|
||||
});
|
||||
@@ -28,7 +28,6 @@ type FeishuMessageReceiveHandlerContext = {
|
||||
chatHistories?: Map<string, HistoryEntry[]>;
|
||||
accountId?: string;
|
||||
processingClaimHeld?: boolean;
|
||||
messageDedupeKey?: string;
|
||||
}) => Promise<void>;
|
||||
resolveDebounceText: (params: {
|
||||
event: FeishuMessageEvent;
|
||||
@@ -185,7 +184,7 @@ export function createFeishuMessageReceiveHandler({
|
||||
},
|
||||
});
|
||||
|
||||
const dispatchFeishuMessage = async (event: FeishuMessageEvent, messageDedupeKey?: string) => {
|
||||
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
|
||||
const sequentialKey = resolveSequentialKey({
|
||||
accountId,
|
||||
event,
|
||||
@@ -203,7 +202,6 @@ export function createFeishuMessageReceiveHandler({
|
||||
chatHistories,
|
||||
accountId,
|
||||
processingClaimHeld: true,
|
||||
messageDedupeKey,
|
||||
});
|
||||
await enqueue(sequentialKey, task);
|
||||
};
|
||||
@@ -268,7 +266,7 @@ export function createFeishuMessageReceiveHandler({
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await dispatchFeishuMessage(last, resolveFeishuMessageDedupeKey(last));
|
||||
await dispatchFeishuMessage(last);
|
||||
return;
|
||||
}
|
||||
const dedupedEntries = dedupeFeishuDebounceEntriesByDedupeKey(entries);
|
||||
@@ -282,8 +280,10 @@ export function createFeishuMessageReceiveHandler({
|
||||
if (!dispatchEntry) {
|
||||
return;
|
||||
}
|
||||
const dispatchDedupeKey = resolveFeishuMessageDedupeKey(dispatchEntry);
|
||||
await recordSuppressedMessageIds(dedupedEntries, dispatchDedupeKey);
|
||||
await recordSuppressedMessageIds(
|
||||
dedupedEntries,
|
||||
resolveFeishuMessageDedupeKey(dispatchEntry),
|
||||
);
|
||||
const combinedText = freshEntries
|
||||
.map((entry) => resolveDebounceText(entry))
|
||||
.filter(Boolean)
|
||||
@@ -292,22 +292,19 @@ export function createFeishuMessageReceiveHandler({
|
||||
entries: freshEntries,
|
||||
botOpenId: getBotOpenId(accountId),
|
||||
});
|
||||
await dispatchFeishuMessage(
|
||||
{
|
||||
...dispatchEntry,
|
||||
message: {
|
||||
...dispatchEntry.message,
|
||||
...(combinedText.trim()
|
||||
? {
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: combinedText }),
|
||||
}
|
||||
: {}),
|
||||
mentions: mergedMentions ?? dispatchEntry.message.mentions,
|
||||
},
|
||||
await dispatchFeishuMessage({
|
||||
...dispatchEntry,
|
||||
message: {
|
||||
...dispatchEntry.message,
|
||||
...(combinedText.trim()
|
||||
? {
|
||||
message_type: "text",
|
||||
content: JSON.stringify({ text: combinedText }),
|
||||
}
|
||||
: {}),
|
||||
mentions: mergedMentions ?? dispatchEntry.message.mentions,
|
||||
},
|
||||
dispatchDedupeKey,
|
||||
);
|
||||
});
|
||||
},
|
||||
onError: (err, entries) => {
|
||||
for (const entry of entries) {
|
||||
@@ -324,14 +321,6 @@ export function createFeishuMessageReceiveHandler({
|
||||
return;
|
||||
}
|
||||
const messageId = event.message?.message_id?.trim();
|
||||
const botOpenId = getBotOpenId(accountId)?.trim();
|
||||
const senderOpenId = event.sender.sender_id.open_id?.trim();
|
||||
if (botOpenId && senderOpenId === botOpenId) {
|
||||
// Feishu bot receive events identify their sender by open_id. Drop this
|
||||
// account's bot before it can consume a claim or debounce slot.
|
||||
log(`feishu[${accountId}]: dropping self-authored message ${messageId ?? "unknown"}`);
|
||||
return;
|
||||
}
|
||||
const messageDedupeKey = resolveFeishuMessageDedupeKey(event);
|
||||
if (!tryBeginFeishuMessageProcessing(messageDedupeKey, accountId)) {
|
||||
log(`feishu[${accountId}]: dropping duplicate event for message ${messageId}`);
|
||||
|
||||
@@ -858,7 +858,6 @@ export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = create
|
||||
extra: {
|
||||
botTokenSource: account.botTokenSource,
|
||||
baseUrl: account.baseUrl,
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
connected: runtime?.connected ?? false,
|
||||
lastConnectedAt: runtime?.lastConnectedAt ?? null,
|
||||
lastDisconnect: runtime?.lastDisconnect ?? null,
|
||||
|
||||
@@ -30,21 +30,6 @@ describe("MattermostConfigSchema", () => {
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it('rejects dmPolicy="open" without wildcard allowFrom', () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
dmPolicy: "open",
|
||||
});
|
||||
expect(result.success).toBe(false);
|
||||
});
|
||||
|
||||
it('accepts dmPolicy="open" with wildcard allowFrom', () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts documented streaming modes and progress config", () => {
|
||||
const result = MattermostConfigSchema.safeParse({
|
||||
streaming: {
|
||||
|
||||
@@ -11,7 +11,6 @@ vi.mock("./runtime-api.js", () => ({
|
||||
|
||||
describe("mattermost monitor auth", () => {
|
||||
let authorizeMattermostCommandInvocation: typeof import("./monitor-auth.js").authorizeMattermostCommandInvocation;
|
||||
let formatMattermostDirectMessageDropLog: typeof import("./monitor-auth.js").formatMattermostDirectMessageDropLog;
|
||||
let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed;
|
||||
let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry;
|
||||
let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList;
|
||||
@@ -19,7 +18,6 @@ describe("mattermost monitor auth", () => {
|
||||
beforeAll(async () => {
|
||||
({
|
||||
authorizeMattermostCommandInvocation,
|
||||
formatMattermostDirectMessageDropLog,
|
||||
isMattermostSenderAllowed,
|
||||
normalizeMattermostAllowEntry,
|
||||
normalizeMattermostAllowList,
|
||||
@@ -60,18 +58,6 @@ describe("mattermost monitor auth", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("formats direct-message drops with the ingress reason and open-policy hint", () => {
|
||||
expect(
|
||||
formatMattermostDirectMessageDropLog({
|
||||
senderId: "alice-id",
|
||||
dmPolicy: "open",
|
||||
reasonCode: "dm_policy_not_allowlisted",
|
||||
}),
|
||||
).toBe(
|
||||
"mattermost: drop dm sender=alice-id (dmPolicy=open reason=dm_policy_not_allowlisted hint=add-allowFrom-wildcard)",
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves direct command authorization from shared ingress", async () => {
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(false);
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });
|
||||
|
||||
@@ -61,19 +61,6 @@ export function normalizeMattermostAllowList(entries: Array<string | number>): s
|
||||
return uniqueStrings(normalized);
|
||||
}
|
||||
|
||||
export function formatMattermostDirectMessageDropLog(params: {
|
||||
senderId: string;
|
||||
dmPolicy: string;
|
||||
reasonCode?: string;
|
||||
}): string {
|
||||
const reason = params.reasonCode ? ` reason=${params.reasonCode}` : "";
|
||||
const hint =
|
||||
params.dmPolicy === "open" && params.reasonCode === "dm_policy_not_allowlisted"
|
||||
? " hint=add-allowFrom-wildcard"
|
||||
: "";
|
||||
return `mattermost: drop dm sender=${params.senderId} (dmPolicy=${params.dmPolicy}${reason}${hint})`;
|
||||
}
|
||||
|
||||
export function isMattermostSenderAllowed(params: {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
|
||||
@@ -57,7 +57,6 @@ import {
|
||||
} from "./model-picker.js";
|
||||
import {
|
||||
authorizeMattermostCommandInvocation,
|
||||
formatMattermostDirectMessageDropLog,
|
||||
normalizeMattermostAllowEntry,
|
||||
resolveMattermostMonitorInboundAccess,
|
||||
} from "./monitor-auth.js";
|
||||
@@ -1390,13 +1389,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
|
||||
}
|
||||
return;
|
||||
}
|
||||
logVerboseMessage(
|
||||
formatMattermostDirectMessageDropLog({
|
||||
senderId,
|
||||
dmPolicy,
|
||||
reasonCode: accessDecision.senderAccess.reasonCode,
|
||||
}),
|
||||
);
|
||||
logVerboseMessage(`mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`);
|
||||
return;
|
||||
}
|
||||
if (accessDecision.ingress.reasonCode === "group_policy_disabled") {
|
||||
|
||||
28
openclaw.mjs
28
openclaw.mjs
@@ -11,8 +11,6 @@ import { fileURLToPath } from "node:url";
|
||||
const MIN_NODE_MAJOR = 22;
|
||||
const MIN_NODE_MINOR = 19;
|
||||
const MIN_NODE_VERSION = `${MIN_NODE_MAJOR}.${MIN_NODE_MINOR}`;
|
||||
const MIN_COMPILE_CACHE_NODE_24_MINOR = 15;
|
||||
const COMPILE_CACHE_DISABLED_RESPAWNED_ENV = "OPENCLAW_COMPILE_CACHE_DISABLED_RESPAWNED";
|
||||
|
||||
const parseNodeVersion = (rawVersion) => {
|
||||
const [majorRaw = "0", minorRaw = "0"] = rawVersion.split(".");
|
||||
@@ -26,15 +24,6 @@ const isSupportedNodeVersion = (version) =>
|
||||
version.major > MIN_NODE_MAJOR ||
|
||||
(version.major === MIN_NODE_MAJOR && version.minor >= MIN_NODE_MINOR);
|
||||
|
||||
const isNodeVersionAffectedByCompileCacheDeadlock = (rawVersion) => {
|
||||
const version = parseNodeVersion(rawVersion);
|
||||
return version.major === 24 && version.minor < MIN_COMPILE_CACHE_NODE_24_MINOR;
|
||||
};
|
||||
|
||||
const shouldSkipCompileCacheForWindowsNode24 = () =>
|
||||
process.platform === "win32" &&
|
||||
isNodeVersionAffectedByCompileCacheDeadlock(process.versions.node);
|
||||
|
||||
const ensureSupportedNodeVersion = () => {
|
||||
if (isSupportedNodeVersion(parseNodeVersion(process.versions.node))) {
|
||||
return;
|
||||
@@ -205,12 +194,10 @@ const runRespawnedChild = (command, args, env) => {
|
||||
};
|
||||
|
||||
const respawnWithoutCompileCacheIfNeeded = () => {
|
||||
const needsDisabledCompileCacheRespawn =
|
||||
isSourceCheckoutLauncher() || shouldSkipCompileCacheForWindowsNode24();
|
||||
if (!needsDisabledCompileCacheRespawn) {
|
||||
if (!isSourceCheckoutLauncher()) {
|
||||
return false;
|
||||
}
|
||||
if (process.env[COMPILE_CACHE_DISABLED_RESPAWNED_ENV] === "1") {
|
||||
if (process.env.OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED === "1") {
|
||||
return false;
|
||||
}
|
||||
if (!module.getCompileCacheDir?.() && !isNodeCompileCacheRequested()) {
|
||||
@@ -219,7 +206,7 @@ const respawnWithoutCompileCacheIfNeeded = () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
[COMPILE_CACHE_DISABLED_RESPAWNED_ENV]: "1",
|
||||
OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1",
|
||||
};
|
||||
delete env.NODE_COMPILE_CACHE;
|
||||
return runRespawnedChild(
|
||||
@@ -230,11 +217,7 @@ const respawnWithoutCompileCacheIfNeeded = () => {
|
||||
};
|
||||
|
||||
const respawnWithPackagedCompileCacheIfNeeded = () => {
|
||||
if (
|
||||
isSourceCheckoutLauncher() ||
|
||||
isNodeCompileCacheDisabled() ||
|
||||
shouldSkipCompileCacheForWindowsNode24()
|
||||
) {
|
||||
if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") {
|
||||
@@ -268,8 +251,7 @@ if (
|
||||
!waitingForCompileCacheRespawn &&
|
||||
module.enableCompileCache &&
|
||||
!isNodeCompileCacheDisabled() &&
|
||||
!isSourceCheckoutLauncher() &&
|
||||
!shouldSkipCompileCacheForWindowsNode24()
|
||||
!isSourceCheckoutLauncher()
|
||||
) {
|
||||
try {
|
||||
module.enableCompileCache(resolvePackagedCompileCacheDirectory());
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { ok, type FileSystem } from "../types.js";
|
||||
import { JsonlSessionStorage, loadJsonlSessionMetadata } from "./jsonl-storage.js";
|
||||
import { Session } from "./session.js";
|
||||
|
||||
type JsonlStorageFs = Pick<
|
||||
FileSystem,
|
||||
@@ -56,209 +55,4 @@ describe("JsonlSessionStorage timestamps", () => {
|
||||
"line 2 has invalid timestamp",
|
||||
);
|
||||
});
|
||||
|
||||
it("uses a leaf control's opaque append parent for the next entry", async () => {
|
||||
let content = [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: "/repo",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
customType: "root",
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive-tail",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "plugin-metadata",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n");
|
||||
content += "\n";
|
||||
const fs: JsonlStorageFs = {
|
||||
...createReadOnlyFs(content),
|
||||
readTextFile: async () => ok(content),
|
||||
appendFile: async (_path, appended) => {
|
||||
content += String(appended);
|
||||
return ok(undefined);
|
||||
},
|
||||
};
|
||||
const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl");
|
||||
const session = new Session(storage);
|
||||
|
||||
expect(await session.getLeafId()).toBe("active-root");
|
||||
const entryId = await session.appendCustomEntry("continued");
|
||||
const entry = await session.getEntry(entryId);
|
||||
|
||||
expect(entry).toMatchObject({ parentId: "plugin-metadata" });
|
||||
expect((await storage.getPathToRoot(entryId)).map((pathEntry) => pathEntry.id)).toEqual([
|
||||
"active-root",
|
||||
entryId,
|
||||
]);
|
||||
expect(content.trim().split(/\r?\n/).at(-1)).toContain('"parentId":"plugin-metadata"');
|
||||
});
|
||||
|
||||
it("keeps a terminal side append off the visible branch", async () => {
|
||||
let content = [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: "/repo",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
customType: "active",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "side-one",
|
||||
parentId: "active-root",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
customType: "side",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "side-leaf",
|
||||
parentId: "side-one",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "side-one",
|
||||
appendMode: "side",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "side-two",
|
||||
parentId: "side-one",
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
customType: "side",
|
||||
appendMode: "side",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n");
|
||||
content += "\n";
|
||||
const fs: JsonlStorageFs = {
|
||||
...createReadOnlyFs(content),
|
||||
readTextFile: async () => ok(content),
|
||||
appendFile: async (_path, appended) => {
|
||||
content += String(appended);
|
||||
return ok(undefined);
|
||||
},
|
||||
};
|
||||
const storage = await JsonlSessionStorage.open(fs, "/sessions/session.jsonl");
|
||||
const session = new Session(storage);
|
||||
|
||||
expect(await storage.getLeafId()).toBe("active-root");
|
||||
expect(await storage.getAppendParentId()).toBe("side-two");
|
||||
const entryId = await session.appendCustomEntry("continued");
|
||||
|
||||
expect(await storage.getEntry(entryId)).toMatchObject({ parentId: "side-two" });
|
||||
expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([
|
||||
"active-root",
|
||||
entryId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not let opaque rows replace the selected visible leaf", async () => {
|
||||
const content = [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: "/repo",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
customType: "active",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "inactive-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
customType: "inactive",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive-root",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: "inactive-root",
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n");
|
||||
const storage = await JsonlSessionStorage.open(
|
||||
createReadOnlyFs(`${content}\n`),
|
||||
"/sessions/session.jsonl",
|
||||
);
|
||||
const session = new Session(storage);
|
||||
|
||||
expect(await session.getLeafId()).toBe("active-root");
|
||||
expect((await session.getBranch()).map((entry) => entry.id)).toEqual(["active-root"]);
|
||||
});
|
||||
|
||||
it("rejects a leaf control with a missing append parent", async () => {
|
||||
const content = [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: "/repo",
|
||||
},
|
||||
{
|
||||
type: "custom",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
customType: "active",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "active-root",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "missing",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n");
|
||||
|
||||
await expect(
|
||||
JsonlSessionStorage.open(createReadOnlyFs(`${content}\n`), "/sessions/session.jsonl"),
|
||||
).rejects.toThrow("Append parent missing not found");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -2,11 +2,7 @@
|
||||
import type { FileSystem, JsonlSessionMetadata, SessionTreeEntry } from "../types.js";
|
||||
import { SessionError, toError } from "../types.js";
|
||||
import { getFileSystemResultOrThrow } from "./repo-utils.js";
|
||||
import {
|
||||
appendParentIdAfterEntry,
|
||||
BaseSessionStorage,
|
||||
leafIdUpdateAfterEntry,
|
||||
} from "./storage-base.js";
|
||||
import { BaseSessionStorage, leafIdAfterEntry } from "./storage-base.js";
|
||||
import { parseSessionTimestampMs } from "./timestamps.js";
|
||||
|
||||
type JsonlSessionStorageFileSystem = Pick<
|
||||
@@ -117,17 +113,6 @@ function parseEntryLine(line: string, filePath: string, lineNumber: number): Ses
|
||||
if (parsed.type === "leaf" && parsed.targetId !== null && typeof parsed.targetId !== "string") {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid targetId");
|
||||
}
|
||||
if (
|
||||
parsed.type === "leaf" &&
|
||||
parsed.appendParentId !== undefined &&
|
||||
parsed.appendParentId !== null &&
|
||||
typeof parsed.appendParentId !== "string"
|
||||
) {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid appendParentId");
|
||||
}
|
||||
if (parsed.appendMode !== undefined && parsed.appendMode !== "side") {
|
||||
throw invalidEntry(filePath, lineNumber, "has invalid appendMode");
|
||||
}
|
||||
return parsed as unknown as SessionTreeEntry;
|
||||
}
|
||||
|
||||
@@ -164,7 +149,6 @@ async function loadJsonlStorage(
|
||||
header: SessionHeader;
|
||||
entries: SessionTreeEntry[];
|
||||
leafId: string | null;
|
||||
appendParentId: string | null;
|
||||
}> {
|
||||
const content = getFileSystemResultOrThrow(
|
||||
await fs.readTextFile(filePath),
|
||||
@@ -178,17 +162,12 @@ async function loadJsonlStorage(
|
||||
const header = parseHeaderLine(lines[0], filePath);
|
||||
const entries: SessionTreeEntry[] = [];
|
||||
let leafId: string | null = null;
|
||||
let appendParentId: string | null = null;
|
||||
for (let i = 1; i < lines.length; i++) {
|
||||
const entry = parseEntryLine(lines[i], filePath, i + 1);
|
||||
entries.push(entry);
|
||||
const leafUpdate = leafIdUpdateAfterEntry(entry);
|
||||
if (leafUpdate !== undefined) {
|
||||
leafId = leafUpdate;
|
||||
}
|
||||
appendParentId = appendParentIdAfterEntry(entry);
|
||||
leafId = leafIdAfterEntry(entry);
|
||||
}
|
||||
return { header, entries, leafId, appendParentId };
|
||||
return { header, entries, leafId };
|
||||
}
|
||||
|
||||
/** Append-only JSONL-backed storage for one session tree. */
|
||||
@@ -202,9 +181,8 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
|
||||
header: SessionHeader,
|
||||
entries: SessionTreeEntry[],
|
||||
leafId: string | null,
|
||||
appendParentId: string | null,
|
||||
) {
|
||||
super(headerToSessionMetadata(header, filePath), entries, leafId, appendParentId);
|
||||
super(headerToSessionMetadata(header, filePath), entries, leafId);
|
||||
this.fs = fs;
|
||||
this.filePath = filePath;
|
||||
}
|
||||
@@ -214,14 +192,7 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
|
||||
filePath: string,
|
||||
): Promise<JsonlSessionStorage> {
|
||||
const loaded = await loadJsonlStorage(fs, filePath);
|
||||
return new JsonlSessionStorage(
|
||||
fs,
|
||||
filePath,
|
||||
loaded.header,
|
||||
loaded.entries,
|
||||
loaded.leafId,
|
||||
loaded.appendParentId,
|
||||
);
|
||||
return new JsonlSessionStorage(fs, filePath, loaded.header, loaded.entries, loaded.leafId);
|
||||
}
|
||||
|
||||
/** Create a new JSONL file with a session header and no entries. */
|
||||
@@ -246,7 +217,7 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
|
||||
await fs.writeFile(filePath, `${JSON.stringify(header)}\n`),
|
||||
`Failed to create session ${filePath}`,
|
||||
);
|
||||
return new JsonlSessionStorage(fs, filePath, header, [], null, null);
|
||||
return new JsonlSessionStorage(fs, filePath, header, [], null);
|
||||
}
|
||||
|
||||
override async setLeafId(leafId: string | null): Promise<void> {
|
||||
@@ -259,7 +230,6 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
|
||||
}
|
||||
|
||||
override async appendEntry(entry: SessionTreeEntry): Promise<void> {
|
||||
this.validateEntryForAppend(entry);
|
||||
getFileSystemResultOrThrow(
|
||||
await this.fs.appendFile(this.filePath, `${JSON.stringify(entry)}\n`),
|
||||
`Failed to append session entry ${entry.id}`,
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { SessionTreeEntry } from "../types.js";
|
||||
import { InMemorySessionStorage } from "./memory-storage.js";
|
||||
import { Session } from "./session.js";
|
||||
|
||||
const rootEntry: SessionTreeEntry = {
|
||||
type: "custom",
|
||||
@@ -61,120 +60,4 @@ describe("InMemorySessionStorage", () => {
|
||||
targetId: "root",
|
||||
});
|
||||
});
|
||||
|
||||
it("traverses descendants of leaf markers through the selected target", async () => {
|
||||
const leafEntry: SessionTreeEntry = {
|
||||
type: "leaf",
|
||||
id: "leaf-1",
|
||||
parentId: "child",
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
targetId: "root",
|
||||
};
|
||||
const replacementEntry: SessionTreeEntry = {
|
||||
type: "custom",
|
||||
id: "replacement",
|
||||
parentId: leafEntry.id,
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
customType: "replacement",
|
||||
};
|
||||
const storage = new InMemorySessionStorage({
|
||||
entries: [rootEntry, childEntry, leafEntry, replacementEntry],
|
||||
});
|
||||
|
||||
expect((await storage.getPathToRoot(replacementEntry.id)).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
"replacement",
|
||||
]);
|
||||
expect((await storage.getPathToRoot(leafEntry.id)).map((entry) => entry.id)).toEqual(["root"]);
|
||||
});
|
||||
|
||||
it("honors an explicit root append parent after a visible leaf selection", async () => {
|
||||
const storage = new InMemorySessionStorage({
|
||||
entries: [
|
||||
rootEntry,
|
||||
{
|
||||
type: "leaf",
|
||||
id: "leaf-1",
|
||||
parentId: "root",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
targetId: "root",
|
||||
appendParentId: null,
|
||||
},
|
||||
],
|
||||
});
|
||||
const session = new Session(storage);
|
||||
|
||||
const entryId = await session.appendCustomEntry("new-root");
|
||||
|
||||
expect(await session.getEntry(entryId)).toMatchObject({ parentId: null });
|
||||
expect((await storage.getPathToRoot(entryId)).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
entryId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps marked side ancestry separate from the next active append", async () => {
|
||||
const sideOne: SessionTreeEntry = {
|
||||
type: "custom",
|
||||
id: "side-one",
|
||||
parentId: "root",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
customType: "side",
|
||||
};
|
||||
const sideTwo: SessionTreeEntry = {
|
||||
type: "custom",
|
||||
id: "side-two",
|
||||
parentId: sideOne.id,
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
appendMode: "side",
|
||||
customType: "side",
|
||||
};
|
||||
const storage = new InMemorySessionStorage({
|
||||
entries: [
|
||||
rootEntry,
|
||||
sideOne,
|
||||
{
|
||||
type: "leaf",
|
||||
id: "first-leaf",
|
||||
parentId: sideOne.id,
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
targetId: "root",
|
||||
appendParentId: sideOne.id,
|
||||
appendMode: "side",
|
||||
},
|
||||
sideTwo,
|
||||
],
|
||||
});
|
||||
const session = new Session(storage);
|
||||
|
||||
expect(await storage.getLeafId()).toBe("root");
|
||||
expect(await storage.getAppendParentId()).toBe(sideTwo.id);
|
||||
expect((await storage.getPathToRoot(sideTwo.id)).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
sideOne.id,
|
||||
sideTwo.id,
|
||||
]);
|
||||
|
||||
const nextEntryId = await session.appendCustomEntry("active");
|
||||
expect((await storage.getPathToRoot(nextEntryId)).map((entry) => entry.id)).toEqual([
|
||||
"root",
|
||||
nextEntryId,
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects a leaf entry with a missing append parent before recording it", async () => {
|
||||
const storage = new InMemorySessionStorage({ entries: [rootEntry] });
|
||||
|
||||
await expect(
|
||||
storage.appendEntry({
|
||||
type: "leaf",
|
||||
id: "leaf-1",
|
||||
parentId: "root",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
targetId: "root",
|
||||
appendParentId: "missing",
|
||||
}),
|
||||
).rejects.toThrow("Append parent missing not found");
|
||||
expect(await storage.getEntries()).toEqual([rootEntry]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -122,10 +122,6 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.storage.getLeafId();
|
||||
}
|
||||
|
||||
private getAppendParentId(): Promise<string | null> {
|
||||
return this.storage.getAppendParentId?.() ?? this.storage.getLeafId();
|
||||
}
|
||||
|
||||
getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
return this.storage.getEntry(id);
|
||||
}
|
||||
@@ -161,7 +157,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "message",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
} satisfies MessageEntry);
|
||||
@@ -171,7 +167,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "thinking_level_change",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
} satisfies ThinkingLevelChangeEntry);
|
||||
@@ -181,7 +177,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "model_change",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
@@ -198,7 +194,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "compaction",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
@@ -213,7 +209,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "custom",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
customType,
|
||||
data,
|
||||
@@ -230,7 +226,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "custom_message",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
customType,
|
||||
content,
|
||||
@@ -247,7 +243,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "label",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId,
|
||||
label,
|
||||
@@ -258,7 +254,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "session_info",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
name: name.trim(),
|
||||
} satisfies SessionInfoEntry);
|
||||
|
||||
@@ -28,10 +28,6 @@ function buildLabelsById(entries: SessionTreeEntry[]): Map<string, string> {
|
||||
return labelsById;
|
||||
}
|
||||
|
||||
function isSideAppendEntry(entry: SessionTreeEntry): boolean {
|
||||
return entry.appendMode === "side";
|
||||
}
|
||||
|
||||
function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
for (let i = 0; i < 100; i++) {
|
||||
const id = uuidv7().slice(0, 8);
|
||||
@@ -42,81 +38,19 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
/** Return the visible-leaf update represented by one session tree entry. */
|
||||
export function leafIdUpdateAfterEntry(entry: SessionTreeEntry): string | null | undefined {
|
||||
if (entry.type !== "leaf" && isSideAppendEntry(entry)) {
|
||||
return undefined;
|
||||
}
|
||||
switch (entry.type) {
|
||||
case "leaf":
|
||||
return entry.targetId;
|
||||
case "message":
|
||||
case "thinking_level_change":
|
||||
case "model_change":
|
||||
case "compaction":
|
||||
case "branch_summary":
|
||||
case "custom":
|
||||
case "custom_message":
|
||||
case "label":
|
||||
case "session_info":
|
||||
return entry.id;
|
||||
default:
|
||||
// JSONL transcripts may contain parent-linked plugin rows that advance
|
||||
// the raw append cursor without selecting a model-visible branch.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/** Return the raw parent for the next append after applying a tree entry. */
|
||||
export function appendParentIdAfterEntry(entry: SessionTreeEntry): string | null {
|
||||
return entry.type === "leaf"
|
||||
? entry.appendParentId === undefined
|
||||
? entry.targetId
|
||||
: entry.appendParentId
|
||||
: entry.id;
|
||||
/** Return the effective branch leaf after applying a session tree entry. */
|
||||
export function leafIdAfterEntry(entry: SessionTreeEntry): string | null {
|
||||
return entry.type === "leaf" ? entry.targetId : entry.id;
|
||||
}
|
||||
|
||||
function resolveLeafId(entries: readonly SessionTreeEntry[]): string | null {
|
||||
let leafId: string | null = null;
|
||||
for (const entry of entries) {
|
||||
const update = leafIdUpdateAfterEntry(entry);
|
||||
if (update !== undefined) {
|
||||
leafId = update;
|
||||
}
|
||||
leafId = leafIdAfterEntry(entry);
|
||||
}
|
||||
return leafId;
|
||||
}
|
||||
|
||||
function resolveAppendParentId(entries: readonly SessionTreeEntry[]): string | null {
|
||||
let appendParentId: string | null = null;
|
||||
for (const entry of entries) {
|
||||
appendParentId = appendParentIdAfterEntry(entry);
|
||||
}
|
||||
return appendParentId;
|
||||
}
|
||||
|
||||
function buildLogicalParentsById(entries: readonly SessionTreeEntry[]): Map<string, string | null> {
|
||||
const logicalParentsById = new Map<string, string | null>();
|
||||
let leafId: string | null = null;
|
||||
let appendParentId: string | null = null;
|
||||
for (const entry of entries) {
|
||||
const leafUpdate = leafIdUpdateAfterEntry(entry);
|
||||
if (
|
||||
leafUpdate === entry.id &&
|
||||
!isSideAppendEntry(entry) &&
|
||||
entry.parentId === appendParentId &&
|
||||
leafId !== appendParentId
|
||||
) {
|
||||
logicalParentsById.set(entry.id, leafId);
|
||||
}
|
||||
if (leafUpdate !== undefined) {
|
||||
leafId = leafUpdate;
|
||||
}
|
||||
appendParentId = appendParentIdAfterEntry(entry);
|
||||
}
|
||||
return logicalParentsById;
|
||||
}
|
||||
|
||||
export abstract class BaseSessionStorage<
|
||||
TMetadata extends SessionMetadata = SessionMetadata,
|
||||
> implements SessionStorage<TMetadata> {
|
||||
@@ -124,29 +58,21 @@ export abstract class BaseSessionStorage<
|
||||
private readonly entries: SessionTreeEntry[];
|
||||
private readonly byId: Map<string, SessionTreeEntry>;
|
||||
private readonly labelsById: Map<string, string>;
|
||||
private readonly logicalParentsById: Map<string, string | null>;
|
||||
private leafId: string | null;
|
||||
private appendParentId: string | null;
|
||||
|
||||
protected constructor(
|
||||
metadata: TMetadata,
|
||||
entries: SessionTreeEntry[],
|
||||
leafId: string | null = resolveLeafId(entries),
|
||||
appendParentId: string | null = resolveAppendParentId(entries),
|
||||
) {
|
||||
this.metadata = metadata;
|
||||
this.entries = entries;
|
||||
this.byId = new Map(entries.map((entry) => [entry.id, entry]));
|
||||
this.labelsById = buildLabelsById(entries);
|
||||
this.logicalParentsById = buildLogicalParentsById(entries);
|
||||
this.leafId = leafId;
|
||||
this.appendParentId = appendParentId;
|
||||
if (this.leafId !== null && !this.byId.has(this.leafId)) {
|
||||
throw new SessionError("invalid_session", `Entry ${this.leafId} not found`);
|
||||
}
|
||||
if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) {
|
||||
throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
async getMetadata(): Promise<TMetadata> {
|
||||
@@ -160,13 +86,6 @@ export abstract class BaseSessionStorage<
|
||||
return this.leafId;
|
||||
}
|
||||
|
||||
async getAppendParentId(): Promise<string | null> {
|
||||
if (this.appendParentId !== null && !this.byId.has(this.appendParentId)) {
|
||||
throw new SessionError("invalid_session", `Append parent ${this.appendParentId} not found`);
|
||||
}
|
||||
return this.appendParentId;
|
||||
}
|
||||
|
||||
protected createLeafEntry(leafId: string | null): LeafEntry {
|
||||
if (leafId !== null && !this.byId.has(leafId)) {
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
@@ -174,7 +93,7 @@ export abstract class BaseSessionStorage<
|
||||
return {
|
||||
type: "leaf",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: leafId,
|
||||
};
|
||||
@@ -184,40 +103,13 @@ export abstract class BaseSessionStorage<
|
||||
return generateEntryId(this.byId);
|
||||
}
|
||||
|
||||
protected validateEntryForAppend(entry: SessionTreeEntry): void {
|
||||
const leafId = leafIdUpdateAfterEntry(entry);
|
||||
const leafIsNewEntry = entry.type !== "leaf" && leafId === entry.id;
|
||||
if (leafId !== undefined && leafId !== null && !leafIsNewEntry && !this.byId.has(leafId)) {
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
|
||||
const appendParentId = appendParentIdAfterEntry(entry);
|
||||
const appendParentIsNewEntry = entry.type !== "leaf" && appendParentId === entry.id;
|
||||
if (appendParentId !== null && !appendParentIsNewEntry && !this.byId.has(appendParentId)) {
|
||||
throw new SessionError("not_found", `Append parent ${appendParentId} not found`);
|
||||
}
|
||||
}
|
||||
|
||||
protected recordEntry(entry: SessionTreeEntry): void {
|
||||
// Leaf and label entries are append-only state changes; keep derived indexes
|
||||
// synchronized here so memory and JSONL storage expose identical behavior.
|
||||
this.validateEntryForAppend(entry);
|
||||
const leafId = leafIdUpdateAfterEntry(entry);
|
||||
if (
|
||||
leafId === entry.id &&
|
||||
!isSideAppendEntry(entry) &&
|
||||
entry.parentId === this.appendParentId &&
|
||||
this.leafId !== this.appendParentId
|
||||
) {
|
||||
this.logicalParentsById.set(entry.id, this.leafId);
|
||||
}
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
updateLabelCache(this.labelsById, entry);
|
||||
if (leafId !== undefined) {
|
||||
this.leafId = leafId;
|
||||
}
|
||||
this.appendParentId = appendParentIdAfterEntry(entry);
|
||||
this.leafId = leafIdAfterEntry(entry);
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
@@ -245,29 +137,14 @@ export abstract class BaseSessionStorage<
|
||||
if (!current) {
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
while (current) {
|
||||
if (seen.has(current.id)) {
|
||||
throw new SessionError("invalid_session", `Cycle found at entry ${current.id}`);
|
||||
}
|
||||
seen.add(current.id);
|
||||
if (current.type !== "leaf") {
|
||||
path.unshift(current);
|
||||
}
|
||||
// Leaf rows are control records. Descendants written by older appenders
|
||||
// may point at the marker, but their visible ancestry starts at its target.
|
||||
const parentId =
|
||||
current.type === "leaf"
|
||||
? current.targetId
|
||||
: this.logicalParentsById.has(current.id)
|
||||
? (this.logicalParentsById.get(current.id) ?? null)
|
||||
: current.parentId;
|
||||
if (!parentId) {
|
||||
path.unshift(current);
|
||||
if (!current.parentId) {
|
||||
break;
|
||||
}
|
||||
const parent = this.byId.get(parentId);
|
||||
const parent = this.byId.get(current.parentId);
|
||||
if (!parent) {
|
||||
throw new SessionError("invalid_session", `Entry ${parentId} not found`);
|
||||
throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
@@ -374,8 +374,6 @@ export interface SessionTreeEntryBase {
|
||||
parentId: string | null;
|
||||
/** ISO timestamp string used for persistence and sorting. */
|
||||
timestamp: string;
|
||||
/** This row consumes the raw side cursor instead of the visible leaf. */
|
||||
appendMode?: "side";
|
||||
}
|
||||
|
||||
/** Persisted transcript message entry. */
|
||||
@@ -450,8 +448,6 @@ export interface SessionInfoEntry extends SessionTreeEntryBase {
|
||||
export interface LeafEntry extends SessionTreeEntryBase {
|
||||
type: "leaf";
|
||||
targetId: string | null;
|
||||
/** Raw parent for the next append when it differs from the visible leaf. */
|
||||
appendParentId?: string | null;
|
||||
}
|
||||
|
||||
/** All persisted session tree entry variants. */
|
||||
@@ -487,7 +483,6 @@ export interface JsonlSessionMetadata extends SessionMetadata {
|
||||
export interface SessionStorage<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
getMetadata(): Promise<TMetadata>;
|
||||
getLeafId(): Promise<string | null>;
|
||||
getAppendParentId?(): Promise<string | null>;
|
||||
/** Persist a leaf entry that records the active session-tree leaf. */
|
||||
setLeafId(leafId: string | null): Promise<void>;
|
||||
createEntryId(): Promise<string>;
|
||||
|
||||
@@ -34,7 +34,6 @@ for env_key in \
|
||||
ANTHROPIC_API_KEY \
|
||||
ANTHROPIC_API_KEY_OLD \
|
||||
ANTHROPIC_API_TOKEN \
|
||||
ANTHROPIC_OAUTH_TOKEN \
|
||||
BYTEPLUS_API_KEY \
|
||||
CEREBRAS_API_KEY \
|
||||
DEEPINFRA_API_KEY \
|
||||
|
||||
@@ -43,6 +43,7 @@ export const KNIP_OPTIONAL_UNUSED_FILE_ALLOWLIST = [
|
||||
"src/plugins/contracts/tts-contract-suites.ts",
|
||||
"src/plugins/runtime-sidecar-paths-baseline.ts",
|
||||
"src/tasks/task-registry-control.runtime.ts",
|
||||
"ui/src/ui/browser-redact.ts",
|
||||
"extensions/qa-lab/src/auth-profile.fixture.ts",
|
||||
"extensions/qa-lab/src/codex-plugin.fixture.ts",
|
||||
];
|
||||
|
||||
@@ -40,17 +40,13 @@ CLICKCLACK_SERVER_LOG="$LOG_DIR/clickclack-server.log"
|
||||
GATEWAY_LOG="$LOG_DIR/gateway.log"
|
||||
MOCK_REQUEST_LOG="$scenario_tmp/openai-requests.jsonl"
|
||||
CLICKCLACK_STATE="$scenario_tmp/clickclack.json"
|
||||
BASELINE_SPEC="${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-openclaw@latest}"
|
||||
export SUCCESS_MARKER MOCK_REQUEST_LOG CLICKCLACK_STATE
|
||||
|
||||
candidate_version="$(
|
||||
tar -xOf "${OPENCLAW_CURRENT_PACKAGE_TGZ:?missing OPENCLAW_CURRENT_PACKAGE_TGZ}" package/package.json |
|
||||
node -e 'let raw = ""; process.stdin.setEncoding("utf8"); process.stdin.on("data", (chunk) => { raw += chunk; }); process.stdin.on("end", () => { process.stdout.write(JSON.parse(raw).version); });'
|
||||
)"
|
||||
if [ -n "${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-}" ]; then
|
||||
BASELINE_SPEC="$OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC"
|
||||
else
|
||||
BASELINE_SPEC="$(node scripts/lib/release-upgrade-baseline.mjs --candidate-version "$candidate_version")"
|
||||
fi
|
||||
|
||||
mock_pid=""
|
||||
clickclack_pid=""
|
||||
|
||||
@@ -53,9 +53,9 @@ import {
|
||||
|
||||
// Older published baselines predate this warning, but still need update coverage.
|
||||
const BAD_PLUGIN_DIAGNOSTIC_MIN_VERSION = "2026.5.7";
|
||||
// Restored Ubuntu snapshots may immediately run package maintenance for hours.
|
||||
// Reuse an existing downloader before touching apt, then bound the fallback.
|
||||
const APT_LOCK_RETRY_SECONDS = 900;
|
||||
// Restored Ubuntu snapshots may immediately run unattended-upgrades. Let that
|
||||
// legitimate maintenance finish instead of racing or disabling the OS service.
|
||||
const APT_LOCK_TIMEOUT_SECONDS = 900;
|
||||
const BOOTSTRAP_TIMEOUT_SECONDS = 1200;
|
||||
|
||||
function parseOpenClawPackageVersion(value: string): string | null {
|
||||
@@ -445,44 +445,27 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
|
||||
this.guestExec(["hwclock", "--systohc"], { check: false });
|
||||
this.guestExec(["timedatectl", "set-ntp", "true"], { check: false });
|
||||
this.guestExec(["systemctl", "restart", "systemd-timesyncd"], { check: false });
|
||||
this.guest.bash(`
|
||||
set -e
|
||||
if command -v curl >/dev/null 2>&1 || command -v wget >/dev/null 2>&1; then
|
||||
exit 0
|
||||
fi
|
||||
deadline=$((SECONDS + ${APT_LOCK_RETRY_SECONDS}))
|
||||
run_apt_with_lock_retry() {
|
||||
local output status
|
||||
while true; do
|
||||
if output="$("$@" 2>&1)"; then
|
||||
status=0
|
||||
else
|
||||
status=$?
|
||||
fi
|
||||
printf '%s\n' "$output"
|
||||
if [ "$status" -eq 0 ]; then
|
||||
return 0
|
||||
fi
|
||||
case "$output" in
|
||||
*"Could not get lock"*|*"Unable to acquire the dpkg frontend lock"*|*"Unable to lock directory"*)
|
||||
if [ "$SECONDS" -ge "$deadline" ]; then
|
||||
printf 'Timed out waiting for Ubuntu package maintenance locks\n' >&2
|
||||
return "$status"
|
||||
fi
|
||||
sleep 5
|
||||
;;
|
||||
*)
|
||||
return "$status"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
run_apt_with_lock_retry apt-get -o Acquire::Check-Date=false -o DPkg::Lock::Timeout=30 update
|
||||
run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-certificates`);
|
||||
this.guestExec([
|
||||
"apt-get",
|
||||
"-o",
|
||||
"Acquire::Check-Date=false",
|
||||
"-o",
|
||||
`DPkg::Lock::Timeout=${APT_LOCK_TIMEOUT_SECONDS}`,
|
||||
"update",
|
||||
]);
|
||||
this.guestExec([
|
||||
"apt-get",
|
||||
"-o",
|
||||
`DPkg::Lock::Timeout=${APT_LOCK_TIMEOUT_SECONDS}`,
|
||||
"install",
|
||||
"-y",
|
||||
"curl",
|
||||
"ca-certificates",
|
||||
]);
|
||||
}
|
||||
|
||||
private installLatestRelease(): void {
|
||||
this.downloadGuestFile(this.options.installUrl, "/tmp/openclaw-install.sh");
|
||||
this.guestExec(["curl", "-fsSL", this.options.installUrl, "-o", "/tmp/openclaw-install.sh"]);
|
||||
if (this.options.installVersion) {
|
||||
this.guestExec([
|
||||
"/usr/bin/env",
|
||||
@@ -505,22 +488,12 @@ run_apt_with_lock_retry apt-get -o DPkg::Lock::Timeout=30 install -y curl ca-cer
|
||||
this.guestExec(["openclaw", "--version"]);
|
||||
}
|
||||
|
||||
private downloadGuestFile(url: string, outputPath: string): void {
|
||||
this.guest.bash(`
|
||||
set -e
|
||||
if command -v curl >/dev/null 2>&1; then
|
||||
curl -fsSL ${shellQuote(url)} -o ${shellQuote(outputPath)}
|
||||
else
|
||||
wget -q -O ${shellQuote(outputPath)} ${shellQuote(url)}
|
||||
fi`);
|
||||
}
|
||||
|
||||
private installMainTgz(tempName: string): void {
|
||||
if (!this.artifact || !this.server) {
|
||||
die("package artifact/server missing");
|
||||
}
|
||||
const tgzUrl = this.server.urlFor(this.artifact.path);
|
||||
this.downloadGuestFile(tgzUrl, `/tmp/${tempName}`);
|
||||
this.guestExec(["curl", "-fsSL", tgzUrl, "-o", `/tmp/${tempName}`]);
|
||||
this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]);
|
||||
this.guestExec(["openclaw", "--version"]);
|
||||
}
|
||||
|
||||
@@ -24,17 +24,11 @@ docker_e2e_build_or_reuse "$IMAGE_NAME" release-upgrade-user-journey "$ROOT_DIR/
|
||||
OPENCLAW_TEST_STATE_SCRIPT_B64="$(docker_e2e_test_state_shell_b64 release-upgrade-user-journey empty)"
|
||||
|
||||
run_log="$(docker_e2e_run_log release-upgrade-user-journey)"
|
||||
DOCKER_ENV_ARGS=(
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64"
|
||||
)
|
||||
if [ -n "${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-}" ]; then
|
||||
DOCKER_ENV_ARGS+=(-e "OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=$OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC")
|
||||
fi
|
||||
|
||||
echo "Running release upgrade user journey Docker E2E..."
|
||||
if ! docker_e2e_run_with_harness \
|
||||
"${DOCKER_ENV_ARGS[@]}" \
|
||||
-e COREPACK_ENABLE_DOWNLOAD_PROMPT=0 \
|
||||
-e "OPENCLAW_TEST_STATE_SCRIPT_B64=$OPENCLAW_TEST_STATE_SCRIPT_B64" \
|
||||
-e "OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC=${OPENCLAW_RELEASE_UPGRADE_BASELINE_SPEC:-openclaw@latest}" \
|
||||
"${DOCKER_E2E_PACKAGE_ARGS[@]}" \
|
||||
-i "$IMAGE_NAME" bash scripts/e2e/lib/release-upgrade-user-journey/scenario.sh >"$run_log" 2>&1; then
|
||||
docker_e2e_print_log "$run_log"
|
||||
|
||||
@@ -1,115 +0,0 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { parseReleaseVersion } from "./npm-publish-plan.mjs";
|
||||
|
||||
function parseVersion(version) {
|
||||
return parseReleaseVersion(String(version ?? "").trim()) ?? undefined;
|
||||
}
|
||||
|
||||
export function compareOpenClawVersions(leftVersion, rightVersion) {
|
||||
const left = parseVersion(leftVersion);
|
||||
const right = parseVersion(rightVersion);
|
||||
if (!left || !right) {
|
||||
throw new Error(`cannot compare OpenClaw versions: ${leftVersion} ${rightVersion}`);
|
||||
}
|
||||
for (const key of ["year", "month", "patch"]) {
|
||||
const delta = left[key] - right[key];
|
||||
if (delta !== 0) {
|
||||
return delta;
|
||||
}
|
||||
}
|
||||
const channelRank = { alpha: 0, beta: 1, stable: 2 };
|
||||
const channelDelta = channelRank[left.channel] - channelRank[right.channel];
|
||||
if (channelDelta !== 0) {
|
||||
return channelDelta;
|
||||
}
|
||||
if (left.channel === "alpha") {
|
||||
return (left.alphaNumber ?? 0) - (right.alphaNumber ?? 0);
|
||||
}
|
||||
if (left.channel === "beta") {
|
||||
return (left.betaNumber ?? 0) - (right.betaNumber ?? 0);
|
||||
}
|
||||
return (left.correctionNumber ?? 0) - (right.correctionNumber ?? 0);
|
||||
}
|
||||
|
||||
function normalizePublishedVersions(publishedVersions) {
|
||||
return [...new Set(publishedVersions.map((version) => String(version).trim()).filter(Boolean))]
|
||||
.filter((version) => parseVersion(version))
|
||||
.toSorted((left, right) => compareOpenClawVersions(right, left));
|
||||
}
|
||||
|
||||
export function resolveDefaultReleaseUpgradeBaseline(candidateVersion, publishedVersions) {
|
||||
const candidate = parseVersion(candidateVersion);
|
||||
if (!candidate) {
|
||||
throw new Error(`invalid candidate OpenClaw version: ${candidateVersion}`);
|
||||
}
|
||||
|
||||
const versions = normalizePublishedVersions(publishedVersions);
|
||||
const older = versions.find((version) => compareOpenClawVersions(version, candidate.version) < 0);
|
||||
if (older) {
|
||||
return `openclaw@${older}`;
|
||||
}
|
||||
|
||||
const same = versions.find(
|
||||
(version) => compareOpenClawVersions(version, candidate.version) === 0,
|
||||
);
|
||||
if (same) {
|
||||
return `openclaw@${same}`;
|
||||
}
|
||||
|
||||
throw new Error(`no published OpenClaw baseline is <= candidate ${candidate.version}`);
|
||||
}
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = new Map();
|
||||
for (let index = 0; index < argv.length; index += 1) {
|
||||
const arg = argv[index];
|
||||
if (!arg.startsWith("--")) {
|
||||
throw new Error(`unexpected argument: ${arg}`);
|
||||
}
|
||||
const key = arg.slice(2);
|
||||
const value = argv[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) {
|
||||
throw new Error(`missing value for --${key}`);
|
||||
}
|
||||
args.set(key, value);
|
||||
index += 1;
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
function readPublishedVersions(args) {
|
||||
const versionsJson = args.get("versions-json");
|
||||
if (versionsJson) {
|
||||
const parsed = JSON.parse(readFileSync(versionsJson, "utf8"));
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error(`npm versions list must be a JSON array: ${versionsJson}`);
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
const raw = execFileSync("npm", ["view", "openclaw", "versions", "--json", "--silent"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "inherit"],
|
||||
});
|
||||
const parsed = JSON.parse(raw);
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("npm returned a non-array openclaw versions payload");
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const isMain = process.argv[1] ? fileURLToPath(import.meta.url) === process.argv[1] : false;
|
||||
|
||||
if (isMain) {
|
||||
const args = parseArgs(process.argv.slice(2));
|
||||
const candidateVersion = args.get("candidate-version");
|
||||
if (!candidateVersion) {
|
||||
throw new Error("--candidate-version is required");
|
||||
}
|
||||
const baseline = resolveDefaultReleaseUpgradeBaseline(
|
||||
candidateVersion,
|
||||
readPublishedVersions(args),
|
||||
);
|
||||
process.stdout.write(`${baseline}\n`);
|
||||
}
|
||||
@@ -161,7 +161,7 @@ let publicDeprecatedExportsByEntrypointBudget;
|
||||
try {
|
||||
budgets = {
|
||||
publicEntrypoints: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_ENTRYPOINTS", 319),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10271),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10270),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5161),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
|
||||
@@ -103,3 +103,23 @@ export function resolveAgentCredentialMapFromStore(
|
||||
}
|
||||
return credentials;
|
||||
}
|
||||
|
||||
/** Compare agent runtime credential values without broad object equality. */
|
||||
export function agentCredentialsEqual(a: AgentCredential | undefined, b: AgentCredential): boolean {
|
||||
if (!a || typeof a !== "object") {
|
||||
return false;
|
||||
}
|
||||
if (a.type !== b.type) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (a.type === "api_key" && b.type === "api_key") {
|
||||
return a.key === b.key;
|
||||
}
|
||||
|
||||
if (a.type === "oauth" && b.type === "oauth") {
|
||||
return a.access === b.access && a.refresh === b.refresh && a.expires === b.expires;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ import path from "node:path";
|
||||
import type { AgentTool, AgentToolResult } from "openclaw/plugin-sdk/agent-core";
|
||||
import { Type } from "typebox";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import * as windowsEncoding from "../infra/windows-encoding.js";
|
||||
import { createOpenClawReadTool, createSandboxedReadTool } from "./agent-tools.read.js";
|
||||
import { createHostSandboxFsBridge } from "./test-helpers/host-sandbox-fs-bridge.js";
|
||||
|
||||
@@ -33,34 +32,6 @@ function extractToolText(result: unknown): string {
|
||||
}
|
||||
|
||||
describe("createOpenClawCodingTools read behavior", () => {
|
||||
it("uses host decoding only for host-backed sandbox paths", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-encoding-"));
|
||||
await fs.writeFile(path.join(tmpDir, "notes.txt"), "hello", "utf8");
|
||||
const hostBridge = createHostSandboxFsBridge(tmpDir);
|
||||
const remoteBridge = {
|
||||
...hostBridge,
|
||||
resolvePath: (params: Parameters<typeof hostBridge.resolvePath>[0]) => {
|
||||
const { relativePath, containerPath } = hostBridge.resolvePath(params);
|
||||
return { relativePath, containerPath };
|
||||
},
|
||||
};
|
||||
const decodeSpy = vi.spyOn(windowsEncoding, "decodeWindowsTextFileBuffer");
|
||||
|
||||
try {
|
||||
const hostTool = createSandboxedReadTool({ root: tmpDir, bridge: hostBridge });
|
||||
await hostTool.execute("host-read", { path: "notes.txt" });
|
||||
expect(decodeSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
decodeSpy.mockClear();
|
||||
const remoteTool = createSandboxedReadTool({ root: tmpDir, bridge: remoteBridge });
|
||||
await remoteTool.execute("remote-read", { path: "notes.txt" });
|
||||
expect(decodeSpy).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
decodeSpy.mockRestore();
|
||||
await fs.rm(tmpDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("applies sandbox path guards to canonical path", async () => {
|
||||
const tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-sbx-"));
|
||||
const outsidePath = path.join(os.tmpdir(), "openclaw-outside.txt");
|
||||
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from "../infra/fs-safe.js";
|
||||
import { expandHomePrefix, resolveOsHomeDir } from "../infra/home-dir.js";
|
||||
import { hasEncodedFileUrlSeparator, trySafeFileURLToPath } from "../infra/local-file-access.js";
|
||||
import { decodeWindowsTextFileBuffer } from "../infra/windows-encoding.js";
|
||||
import {
|
||||
classifyMediaReferenceSource,
|
||||
normalizeMediaReferenceSource,
|
||||
@@ -918,10 +917,6 @@ function createSandboxReadOperations(params: SandboxToolParams) {
|
||||
}
|
||||
return resolveContainerPathCandidate(filePath) ?? filePath;
|
||||
},
|
||||
decodeText: ({ buffer, absolutePath }: { buffer: Buffer; absolutePath: string }) =>
|
||||
params.bridge.resolvePath({ filePath: absolutePath, cwd: params.root }).hostPath
|
||||
? decodeWindowsTextFileBuffer({ buffer })
|
||||
: buffer.toString("utf8"),
|
||||
readFile: (absolutePath: string) =>
|
||||
params.bridge.readFile({ filePath: absolutePath, cwd: params.root }),
|
||||
access: (absolutePath: string) => assertSandboxFileExists(params, absolutePath),
|
||||
|
||||
@@ -14,7 +14,6 @@ import {
|
||||
getNodeSqliteKysely,
|
||||
} from "../../infra/kysely-sync.js";
|
||||
import { requireNodeSqlite } from "../../infra/node-sqlite.js";
|
||||
import { resolveSqliteDatabaseFilePaths } from "../../infra/sqlite-files.js";
|
||||
import type { DB as OpenClawAgentKyselyDatabase } from "../../state/openclaw-agent-db.generated.js";
|
||||
import {
|
||||
openOpenClawAgentDatabase,
|
||||
@@ -68,7 +67,8 @@ export function resolveAuthProfileDatabasePath(agentDir?: string): string {
|
||||
|
||||
/** Resolves the SQLite database and sidecar paths used by auth profiles. */
|
||||
export function resolveAuthProfileDatabaseFilePaths(agentDir?: string): string[] {
|
||||
return resolveSqliteDatabaseFilePaths(resolveAuthProfileDatabasePath(agentDir));
|
||||
const databasePath = resolveAuthProfileDatabasePath(agentDir);
|
||||
return [databasePath, `${databasePath}-wal`, `${databasePath}-shm`];
|
||||
}
|
||||
|
||||
// Read-only probes must tolerate old/corrupt/missing rows. Coercion happens
|
||||
|
||||
@@ -323,6 +323,11 @@ export function listFinishedSessions() {
|
||||
return Array.from(finishedSessions.values());
|
||||
}
|
||||
|
||||
/** Clears retained finished sessions without touching running processes. */
|
||||
export function clearFinished() {
|
||||
finishedSessions.clear();
|
||||
}
|
||||
|
||||
/** Test-only reset for in-memory registry state and retention timers. */
|
||||
export function resetProcessRegistryForTests() {
|
||||
runningSessions.clear();
|
||||
|
||||
@@ -68,6 +68,16 @@ export async function getOrLoadBootstrapFiles(params: {
|
||||
return files;
|
||||
}
|
||||
|
||||
/** Test helper exposing the bounded snapshot cache size. */
|
||||
export function getBootstrapSnapshotCacheSizeForTest(): number {
|
||||
return cache.size;
|
||||
}
|
||||
|
||||
/** Test helper for asserting one session snapshot is cached. */
|
||||
export function hasBootstrapSnapshotForTest(sessionKey: string): boolean {
|
||||
return cache.has(sessionKey);
|
||||
}
|
||||
|
||||
/** Drop one cached bootstrap snapshot. */
|
||||
export function clearBootstrapSnapshot(sessionKey: string): void {
|
||||
cache.delete(sessionKey);
|
||||
|
||||
@@ -7,10 +7,6 @@ import {
|
||||
resolveSessionFilePathOptions,
|
||||
type SessionEntry as StoredSessionEntry,
|
||||
} from "../config/sessions.js";
|
||||
import {
|
||||
scanSessionTranscriptTree,
|
||||
type SessionTranscriptTree,
|
||||
} from "../config/sessions/transcript-tree.js";
|
||||
import { diagnosticLogger as diag } from "../logging/diagnostic.js";
|
||||
import {
|
||||
buildSessionContext,
|
||||
@@ -48,18 +44,36 @@ function readSessionEntryId(entry: AgentSessionEntry): string | undefined {
|
||||
return typeof id === "string" && id.trim().length > 0 ? id : undefined;
|
||||
}
|
||||
|
||||
function readSessionEntryParentId(entry: AgentSessionEntry): string | null | undefined {
|
||||
const parentId = (entry as { parentId?: unknown }).parentId;
|
||||
if (parentId === null) {
|
||||
return null;
|
||||
}
|
||||
return typeof parentId === "string" && parentId.trim().length > 0 ? parentId : undefined;
|
||||
}
|
||||
|
||||
// Parent links mark fork-aware transcripts. Without them, the flat session
|
||||
// context builder preserves the legacy append-only transcript behavior.
|
||||
function hasParentLinkedEntries(entries: AgentSessionEntry[]): boolean {
|
||||
return entries.some((entry) => Boolean(readSessionEntryId(entry) && "parentId" in entry));
|
||||
}
|
||||
|
||||
// Reconstructs the selected branch from leaf to root. Missing links or cycles
|
||||
// mean the snapshot cannot be trusted, so callers fall back to a safe branch.
|
||||
function buildSessionBranchEntries(
|
||||
tree: SessionTranscriptTree<AgentSessionEntry>,
|
||||
leafId: string | null | undefined,
|
||||
entries: AgentSessionEntry[],
|
||||
leafId: string | undefined,
|
||||
): AgentSessionEntry[] | undefined {
|
||||
if (leafId === null) {
|
||||
return [];
|
||||
}
|
||||
if (!leafId) {
|
||||
return undefined;
|
||||
}
|
||||
const byId = new Map<string, AgentSessionEntry>();
|
||||
for (const entry of entries) {
|
||||
const id = readSessionEntryId(entry);
|
||||
if (id) {
|
||||
byId.set(id, entry);
|
||||
}
|
||||
}
|
||||
const branch: AgentSessionEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
let currentId: string | undefined = leafId;
|
||||
@@ -68,22 +82,26 @@ function buildSessionBranchEntries(
|
||||
return undefined;
|
||||
}
|
||||
seen.add(currentId);
|
||||
const node = tree.byId.get(currentId);
|
||||
if (!node) {
|
||||
const entry = byId.get(currentId);
|
||||
if (!entry) {
|
||||
return undefined;
|
||||
}
|
||||
if ((node.entry as { type?: unknown }).type !== "leaf") {
|
||||
branch.push(
|
||||
node.entry.parentId === node.parentId
|
||||
? node.entry
|
||||
: ({ ...node.entry, parentId: node.parentId } as AgentSessionEntry),
|
||||
);
|
||||
}
|
||||
currentId = node.parentId ?? undefined;
|
||||
branch.push(entry);
|
||||
currentId = readSessionEntryParentId(entry) ?? undefined;
|
||||
}
|
||||
return branch.toReversed();
|
||||
}
|
||||
|
||||
function readDefaultLeafId(entries: AgentSessionEntry[]): string | undefined {
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const id = readSessionEntryId(entries[index]);
|
||||
if (id) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function isTrailingUserMessage(entry: AgentSessionEntry | undefined): boolean {
|
||||
return (
|
||||
entry?.type === "message" &&
|
||||
@@ -109,27 +127,24 @@ export async function readBtwTranscriptMessages(params: {
|
||||
const sessionEntries = entries.filter(
|
||||
(entry): entry is AgentSessionEntry => entry.type !== "session",
|
||||
);
|
||||
const tree = scanSessionTranscriptTree(sessionEntries);
|
||||
if (!tree.hasLeafUpdate) {
|
||||
if (!hasParentLinkedEntries(sessionEntries)) {
|
||||
return buildSessionContext(sessionEntries).messages;
|
||||
}
|
||||
|
||||
const hasSnapshotLeaf = params.snapshotLeafId !== undefined;
|
||||
let branchEntries = hasSnapshotLeaf
|
||||
? buildSessionBranchEntries(tree, params.snapshotLeafId)
|
||||
let branchEntries = params.snapshotLeafId
|
||||
? buildSessionBranchEntries(sessionEntries, params.snapshotLeafId)
|
||||
: undefined;
|
||||
if (hasSnapshotLeaf && branchEntries === undefined) {
|
||||
if (params.snapshotLeafId && !branchEntries) {
|
||||
diag.debug(
|
||||
`btw snapshot leaf unavailable: sessionId=${params.sessionId} leaf=${params.snapshotLeafId}`,
|
||||
);
|
||||
}
|
||||
branchEntries ??= buildSessionBranchEntries(tree, tree.leafId);
|
||||
if (!hasSnapshotLeaf && isTrailingUserMessage(branchEntries?.at(-1))) {
|
||||
branchEntries ??= buildSessionBranchEntries(sessionEntries, readDefaultLeafId(sessionEntries));
|
||||
if (!params.snapshotLeafId && isTrailingUserMessage(branchEntries?.at(-1))) {
|
||||
// Auto-selecting the newest branch must not include the current user turn
|
||||
// that triggered BTW handoff; the subagent should continue from its parent.
|
||||
const trailingId = readSessionEntryId(branchEntries!.at(-1)!);
|
||||
const parentId = trailingId ? tree.byId.get(trailingId)?.parentId : null;
|
||||
branchEntries = parentId ? (buildSessionBranchEntries(tree, parentId) ?? []) : [];
|
||||
const parentId = readSessionEntryParentId(branchEntries!.at(-1)!);
|
||||
branchEntries = parentId ? (buildSessionBranchEntries(sessionEntries, parentId) ?? []) : [];
|
||||
}
|
||||
const sessionContext = buildSessionContext(branchEntries ?? sessionEntries);
|
||||
return Array.isArray(sessionContext.messages) ? sessionContext.messages : [];
|
||||
|
||||
@@ -1482,144 +1482,6 @@ describe("runBtwSideQuestion", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("honors an explicitly empty active run snapshot", async () => {
|
||||
const userEntry = createTranscriptEntry({
|
||||
id: "user-seed",
|
||||
message: createUserTranscriptMessage(),
|
||||
});
|
||||
const assistantEntry = createTranscriptEntry({
|
||||
id: "assistant-seed",
|
||||
parentId: "user-seed",
|
||||
message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]),
|
||||
});
|
||||
mockTranscriptEntries([userEntry, assistantEntry]);
|
||||
getActiveEmbeddedRunSnapshotMock.mockReturnValue({
|
||||
transcriptLeafId: null,
|
||||
});
|
||||
|
||||
await expect(runMathSideQuestion()).rejects.toThrow("No active session context.");
|
||||
|
||||
expect(buildSessionContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(buildSessionContextMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
|
||||
it("uses the branch selected by a terminal transcript leaf control", async () => {
|
||||
const userEntry = createTranscriptEntry({
|
||||
id: "user-seed",
|
||||
message: createUserTranscriptMessage(),
|
||||
});
|
||||
const assistantEntry = createTranscriptEntry({
|
||||
id: "assistant-seed",
|
||||
parentId: "user-seed",
|
||||
message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]),
|
||||
});
|
||||
const sideEntry = createTranscriptEntry({
|
||||
id: "side-delivery",
|
||||
parentId: "assistant-seed",
|
||||
message: createAssistantTranscriptMessage([{ type: "text", text: "side delivery" }]),
|
||||
});
|
||||
const leafEntry = {
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "side-delivery",
|
||||
targetId: "assistant-seed",
|
||||
};
|
||||
mockTranscriptEntries([userEntry, assistantEntry, sideEntry, leafEntry]);
|
||||
mockDoneAnswer(MATH_ANSWER);
|
||||
|
||||
const result = await runMathSideQuestion();
|
||||
|
||||
expect(buildSessionContextMock).toHaveBeenCalledTimes(1);
|
||||
expect(buildSessionContextMock).toHaveBeenCalledWith([userEntry, assistantEntry]);
|
||||
expect(result).toEqual({ text: MATH_ANSWER });
|
||||
});
|
||||
|
||||
it("keeps parentless history addressed by a terminal leaf control", async () => {
|
||||
const userEntry = {
|
||||
type: "message",
|
||||
id: "user-seed",
|
||||
message: createUserTranscriptMessage(),
|
||||
};
|
||||
const assistantEntry = {
|
||||
type: "message",
|
||||
id: "assistant-seed",
|
||||
message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]),
|
||||
};
|
||||
const sideEntry = createTranscriptEntry({
|
||||
id: "side-delivery",
|
||||
parentId: "assistant-seed",
|
||||
message: createAssistantTranscriptMessage([{ type: "text", text: "side delivery" }]),
|
||||
});
|
||||
const leafEntry = {
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "side-delivery",
|
||||
targetId: "assistant-seed",
|
||||
};
|
||||
mockTranscriptEntries([userEntry, assistantEntry, sideEntry, leafEntry]);
|
||||
mockDoneAnswer(MATH_ANSWER);
|
||||
|
||||
const result = await runMathSideQuestion();
|
||||
|
||||
expect(buildSessionContextMock).toHaveBeenCalledWith([
|
||||
{ ...userEntry, parentId: null },
|
||||
{ ...assistantEntry, parentId: "user-seed" },
|
||||
]);
|
||||
expect(result).toEqual({ text: MATH_ANSWER });
|
||||
});
|
||||
|
||||
it("keeps visible history after continuing from a disjoint opaque append cursor", async () => {
|
||||
const userEntry = createTranscriptEntry({
|
||||
id: "user-seed",
|
||||
message: createUserTranscriptMessage(),
|
||||
});
|
||||
const assistantEntry = createTranscriptEntry({
|
||||
id: "assistant-seed",
|
||||
parentId: "user-seed",
|
||||
message: createAssistantTranscriptMessage([{ type: "text", text: "seed answer" }]),
|
||||
});
|
||||
const sideEntry = createTranscriptEntry({
|
||||
id: "side-delivery",
|
||||
parentId: "assistant-seed",
|
||||
message: createAssistantTranscriptMessage([{ type: "text", text: "side delivery" }]),
|
||||
});
|
||||
const metadataEntry = {
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: "side-delivery",
|
||||
};
|
||||
const leafEntry = {
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "side-delivery",
|
||||
targetId: "assistant-seed",
|
||||
appendParentId: "plugin-metadata",
|
||||
};
|
||||
const continuationEntry = createTranscriptEntry({
|
||||
id: "assistant-continuation",
|
||||
parentId: "plugin-metadata",
|
||||
message: createAssistantTranscriptMessage([{ type: "text", text: "continued answer" }]),
|
||||
});
|
||||
mockTranscriptEntries([
|
||||
userEntry,
|
||||
assistantEntry,
|
||||
sideEntry,
|
||||
metadataEntry,
|
||||
leafEntry,
|
||||
continuationEntry,
|
||||
]);
|
||||
mockDoneAnswer(MATH_ANSWER);
|
||||
|
||||
const result = await runMathSideQuestion();
|
||||
|
||||
expect(buildSessionContextMock).toHaveBeenCalledWith([
|
||||
userEntry,
|
||||
assistantEntry,
|
||||
{ ...continuationEntry, parentId: "assistant-seed" },
|
||||
]);
|
||||
expect(result).toEqual({ text: MATH_ANSWER });
|
||||
});
|
||||
|
||||
it("returns the BTW answer without appending transcript custom entries", async () => {
|
||||
mockDoneAnswer(MATH_ANSWER);
|
||||
|
||||
|
||||
@@ -204,65 +204,6 @@ describe("loadCliSessionHistoryMessages", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("loads only the branch selected by transcript leaf controls", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
|
||||
const sessionFile = createSessionTranscript({
|
||||
rootDir: stateDir,
|
||||
sessionId: "session-leaf-control",
|
||||
messages: ["active root"],
|
||||
});
|
||||
fs.appendFileSync(
|
||||
sessionFile,
|
||||
[
|
||||
{
|
||||
type: "message",
|
||||
id: "side-entry",
|
||||
parentId: "msg-0",
|
||||
timestamp: new Date(2).toISOString(),
|
||||
message: { role: "assistant", content: "side delivery", timestamp: 2 },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "side-entry",
|
||||
timestamp: new Date(3).toISOString(),
|
||||
targetId: "msg-0",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "active-tail",
|
||||
parentId: "msg-0",
|
||||
timestamp: new Date(4).toISOString(),
|
||||
message: { role: "assistant", content: "active tail", timestamp: 4 },
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "opaque-after-active-tail",
|
||||
parentId: "side-entry",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
try {
|
||||
await withCliSessionState(stateDir, async () => {
|
||||
const history = await loadCliSessionHistoryMessages({
|
||||
sessionId: "session-leaf-control",
|
||||
sessionFile,
|
||||
sessionKey: "agent:main:main",
|
||||
agentId: "main",
|
||||
});
|
||||
expect(history).toHaveLength(2);
|
||||
expectMessageFields(history[0], { role: "user", content: "active root" });
|
||||
expectMessageFields(history[1], { role: "assistant", content: "active tail" });
|
||||
});
|
||||
} finally {
|
||||
fs.rmSync(stateDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps complete history for context-engine snapshots", async () => {
|
||||
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-cli-state-"));
|
||||
const sessionFile = createSessionTranscript({
|
||||
|
||||
@@ -8,7 +8,6 @@ import {
|
||||
resolveSessionFilePath,
|
||||
resolveSessionFilePathOptions,
|
||||
} from "../../config/sessions/paths.js";
|
||||
import { selectSessionTranscriptLeafControlledPath } from "../../config/sessions/transcript-tree.js";
|
||||
import type { OpenClawConfig } from "../../config/types.openclaw.js";
|
||||
import { isPathInside } from "../../infra/path-guards.js";
|
||||
import { resolveSessionAgentIds } from "../agent-scope.js";
|
||||
@@ -330,8 +329,7 @@ async function loadCliSessionEntries(params: {
|
||||
}
|
||||
const entries = parseSessionEntries(await fsp.readFile(realSessionFile, "utf-8"));
|
||||
migrateSessionEntries(entries);
|
||||
const sessionEntries = entries.filter((entry) => entry.type !== "session");
|
||||
return selectSessionTranscriptLeafControlledPath(sessionEntries) ?? sessionEntries;
|
||||
return entries.filter((entry) => entry.type !== "session");
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -312,43 +312,6 @@ const runDefaultEmbeddedTurn = async (sessionFile: string, prompt: string, sessi
|
||||
});
|
||||
};
|
||||
|
||||
const addAnthropicProvider = (
|
||||
cfg: ReturnType<typeof createEmbeddedAgentRunnerOpenAiConfig>,
|
||||
modelIds: string[],
|
||||
) => ({
|
||||
...cfg,
|
||||
models: {
|
||||
providers: {
|
||||
...cfg.models?.providers,
|
||||
anthropic: {
|
||||
api: "anthropic-messages" as const,
|
||||
apiKey: "sk-test",
|
||||
baseUrl: "https://example.com",
|
||||
models: modelIds.map((id) => ({
|
||||
id,
|
||||
name: `Mock ${id}`,
|
||||
reasoning: false,
|
||||
input: ["text" as const],
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
|
||||
contextWindow: 16_000,
|
||||
maxTokens: 2048,
|
||||
})),
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const mockSuccessfulEmbeddedAttempt = () => {
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeEmbeddedRunnerAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildEmbeddedRunnerAssistant({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
};
|
||||
|
||||
function firstMockCall(mock: { mock: { calls: unknown[][] } }, label: string): unknown[] {
|
||||
const call = mock.mock.calls[0];
|
||||
if (!call) {
|
||||
@@ -375,7 +338,14 @@ describe("runEmbeddedAgent", () => {
|
||||
list: [{ id: "research", model: "openrouter/research-default" }],
|
||||
},
|
||||
};
|
||||
mockSuccessfulEmbeddedAttempt();
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeEmbeddedRunnerAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildEmbeddedRunnerAssistant({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedAgent({
|
||||
sessionId: "configured-default-model",
|
||||
@@ -413,7 +383,14 @@ describe("runEmbeddedAgent", () => {
|
||||
},
|
||||
};
|
||||
setRuntimeConfigSnapshot(cfg);
|
||||
mockSuccessfulEmbeddedAttempt();
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeEmbeddedRunnerAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildEmbeddedRunnerAssistant({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
|
||||
await runEmbeddedAgent({
|
||||
sessionId: "runtime-config-default-model",
|
||||
@@ -438,85 +415,6 @@ describe("runEmbeddedAgent", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("uses the session-key agent default when agentId is inferred", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const cfg = {
|
||||
...addAnthropicProvider(createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]), [
|
||||
"claude-opus-4-7",
|
||||
]),
|
||||
agents: {
|
||||
defaults: {
|
||||
model: { primary: "openai/mock-1" },
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "research",
|
||||
model: { primary: "anthropic/claude-opus-4-7" },
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
mockSuccessfulEmbeddedAttempt();
|
||||
|
||||
await runEmbeddedAgent({
|
||||
sessionId: "session-key-agent-default",
|
||||
sessionKey: "agent:research:embedded:session-key-agent-default",
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: "hello",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
runId: nextRunId("session-key-agent-default"),
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
expect(resolveModelAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"anthropic",
|
||||
"claude-opus-4-7",
|
||||
agentDir,
|
||||
cfg,
|
||||
expect.objectContaining({ skipAgentDiscovery: true }),
|
||||
);
|
||||
expect(
|
||||
(firstRunEmbeddedAttemptParams() as { model?: { provider?: string; id?: string } }).model,
|
||||
).toEqual(expect.objectContaining({ provider: "anthropic", id: "claude-opus-4-7" }));
|
||||
});
|
||||
|
||||
it("resolves model-only provider refs instead of prefixing the default provider", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const cfg = addAnthropicProvider(createEmbeddedAgentRunnerOpenAiConfig(["mock-1"]), [
|
||||
"claude-sonnet-4-6",
|
||||
]);
|
||||
mockSuccessfulEmbeddedAttempt();
|
||||
|
||||
await runEmbeddedAgent({
|
||||
sessionId: "model-only-provider-ref",
|
||||
sessionFile,
|
||||
workspaceDir,
|
||||
config: cfg,
|
||||
prompt: "hello",
|
||||
model: "anthropic/claude-sonnet-4-6",
|
||||
timeoutMs: 5_000,
|
||||
agentDir,
|
||||
runId: nextRunId("model-only-provider-ref"),
|
||||
enqueue: immediateEnqueue,
|
||||
});
|
||||
|
||||
expect(resolveModelAsyncMock).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"anthropic",
|
||||
"claude-sonnet-4-6",
|
||||
agentDir,
|
||||
cfg,
|
||||
expect.objectContaining({ skipAgentDiscovery: true }),
|
||||
);
|
||||
expect(
|
||||
(firstRunEmbeddedAttemptParams() as { model?: { provider?: string; id?: string } }).model,
|
||||
).toEqual(expect.objectContaining({ provider: "anthropic", id: "claude-sonnet-4-6" }));
|
||||
});
|
||||
|
||||
it("skips models.json generation when dynamic model resolution succeeds", async () => {
|
||||
const sessionFile = nextSessionFile();
|
||||
const cfg = createEmbeddedAgentRunnerOpenAiConfig([]);
|
||||
|
||||
@@ -11,8 +11,7 @@ import {
|
||||
captureCompactionCheckpointSnapshotAsync,
|
||||
cleanupCompactionCheckpointSnapshot,
|
||||
persistSessionCompactionCheckpoint,
|
||||
readSessionLeafStateFromTranscriptAsync,
|
||||
resolveCompactionCheckpointTranscriptPosition,
|
||||
readSessionLeafIdFromTranscriptAsync,
|
||||
resolveSessionCompactionCheckpointReason,
|
||||
type CapturedCompactionCheckpointSnapshot,
|
||||
} from "../../gateway/session-compaction-checkpoints.js";
|
||||
@@ -453,12 +452,10 @@ export async function compactEmbeddedAgentSession(
|
||||
}
|
||||
if (params.config && params.sessionKey && checkpointSnapshot) {
|
||||
try {
|
||||
const transcriptState =
|
||||
await readSessionLeafStateFromTranscriptAsync(postCompactionSessionFile);
|
||||
const checkpointPosition = resolveCompactionCheckpointTranscriptPosition({
|
||||
preferredLeafId: postCompactionLeafId,
|
||||
transcriptState,
|
||||
});
|
||||
const postLeafId =
|
||||
postCompactionLeafId ??
|
||||
(await readSessionLeafIdFromTranscriptAsync(postCompactionSessionFile)) ??
|
||||
undefined;
|
||||
const storedCheckpoint = await persistSessionCompactionCheckpoint({
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -472,8 +469,8 @@ export async function compactEmbeddedAgentSession(
|
||||
tokensBefore: result.result?.tokensBefore,
|
||||
tokensAfter: result.result?.tokensAfter,
|
||||
postSessionFile: postCompactionSessionFile,
|
||||
postLeafId: checkpointPosition.leafId,
|
||||
postEntryId: checkpointPosition.entryId,
|
||||
postLeafId,
|
||||
postEntryId: postLeafId,
|
||||
});
|
||||
checkpointSnapshotRetained = storedCheckpoint !== null;
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,8 +11,6 @@ import {
|
||||
captureCompactionCheckpointSnapshotAsync,
|
||||
cleanupCompactionCheckpointSnapshot,
|
||||
persistSessionCompactionCheckpoint,
|
||||
readSessionLeafStateFromTranscriptAsync,
|
||||
resolveCompactionCheckpointTranscriptPosition,
|
||||
resolveSessionCompactionCheckpointReason,
|
||||
type CapturedCompactionCheckpointSnapshot,
|
||||
} from "../../gateway/session-compaction-checkpoints.js";
|
||||
@@ -1506,12 +1504,6 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
});
|
||||
if (params.config && params.sessionKey && checkpointSnapshot) {
|
||||
try {
|
||||
const transcriptState =
|
||||
await readSessionLeafStateFromTranscriptAsync(activeSessionFile);
|
||||
const checkpointPosition = resolveCompactionCheckpointTranscriptPosition({
|
||||
preferredLeafId: activePostLeafId,
|
||||
transcriptState,
|
||||
});
|
||||
const storedCheckpoint = await persistSessionCompactionCheckpoint({
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -1525,8 +1517,8 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
tokensBefore: observedTokenCount ?? result.tokensBefore,
|
||||
tokensAfter,
|
||||
postSessionFile: activeSessionFile,
|
||||
postLeafId: checkpointPosition.leafId,
|
||||
postEntryId: checkpointPosition.entryId,
|
||||
postLeafId: activePostLeafId,
|
||||
postEntryId: activePostLeafId,
|
||||
createdAt: compactStartedAt,
|
||||
});
|
||||
checkpointSnapshotRetained = storedCheckpoint !== null;
|
||||
|
||||
@@ -132,9 +132,6 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
|
||||
runId: "run-cross-provider-fallback-error-context",
|
||||
config: makeCrossProviderFallbackConfig(),
|
||||
agentHarnessRuntimeOverride: "openclaw",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-chat",
|
||||
modelFallbacksOverride: ["deepseek/deepseek-chat"],
|
||||
});
|
||||
|
||||
await expectDeepseekFallbackError(promise, getLastFormattedAssistant);
|
||||
@@ -170,9 +167,6 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
|
||||
runId: "run-compaction-fallback-error-context",
|
||||
config: makeCrossProviderFallbackConfig(),
|
||||
agentHarnessRuntimeOverride: "openclaw",
|
||||
provider: "anthropic",
|
||||
model: "test-model",
|
||||
modelFallbacksOverride: ["deepseek/deepseek-chat"],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(MockedFailoverError);
|
||||
@@ -209,9 +203,6 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
|
||||
runId: "run-stale-session-assistant-timeout",
|
||||
config: makeCrossProviderFallbackConfig(),
|
||||
agentHarnessRuntimeOverride: "openclaw",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-chat",
|
||||
modelFallbacksOverride: ["deepseek/deepseek-chat"],
|
||||
});
|
||||
|
||||
await expect(promise).rejects.toBeInstanceOf(MockedFailoverError);
|
||||
@@ -245,9 +236,6 @@ describe("runEmbeddedAgent cross-provider fallback error handling", () => {
|
||||
runId: "run-stale-session-assistant-non-timeout",
|
||||
config: makeCrossProviderFallbackConfig(),
|
||||
agentHarnessRuntimeOverride: "openclaw",
|
||||
provider: "deepseek",
|
||||
model: "deepseek-chat",
|
||||
modelFallbacksOverride: ["deepseek/deepseek-chat"],
|
||||
});
|
||||
|
||||
expect(mockedIsFailoverAssistantError).toHaveBeenCalledWith(undefined);
|
||||
|
||||
@@ -100,11 +100,7 @@ import {
|
||||
resolveAuthProfileOrder,
|
||||
shouldPreferExplicitConfigApiKeyAuth,
|
||||
} from "../model-auth.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
resolveDefaultModelForAgent,
|
||||
resolveModelRefFromString,
|
||||
} from "../model-selection.js";
|
||||
import { resolveDefaultModelForAgent } from "../model-selection.js";
|
||||
import { resolveThinkingDefault } from "../model-thinking-default.js";
|
||||
import { ensureOpenClawModelsJson } from "../models-config.js";
|
||||
import {
|
||||
@@ -532,49 +528,6 @@ function buildHandledReplyPayloads(reply?: ReplyPayload) {
|
||||
];
|
||||
}
|
||||
|
||||
function resolveInitialEmbeddedRunModel(params: {
|
||||
config: RunEmbeddedAgentParams["config"];
|
||||
agentId?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
}): { provider: string; modelId: string } {
|
||||
const cfg = params.config ?? {};
|
||||
const configuredDefault = resolveDefaultModelForAgent({
|
||||
cfg,
|
||||
agentId: params.agentId,
|
||||
});
|
||||
const explicitProvider = normalizeOptionalString(params.provider);
|
||||
const explicitModel = normalizeOptionalString(params.model);
|
||||
const defaultProvider = configuredDefault.provider || DEFAULT_PROVIDER;
|
||||
|
||||
if (explicitProvider && explicitModel) {
|
||||
return { provider: explicitProvider, modelId: explicitModel };
|
||||
}
|
||||
|
||||
if (explicitModel) {
|
||||
const provider = explicitProvider ?? defaultProvider;
|
||||
const aliasIndex = buildModelAliasIndex({
|
||||
cfg,
|
||||
defaultProvider: provider,
|
||||
});
|
||||
const resolved = resolveModelRefFromString({
|
||||
cfg,
|
||||
raw: explicitModel,
|
||||
defaultProvider: provider,
|
||||
aliasIndex,
|
||||
});
|
||||
return {
|
||||
provider: explicitProvider ?? resolved?.ref.provider ?? provider,
|
||||
modelId: resolved?.ref.model ?? explicitModel,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
provider: explicitProvider ?? defaultProvider,
|
||||
modelId: configuredDefault.model || DEFAULT_MODEL,
|
||||
};
|
||||
}
|
||||
|
||||
export function runEmbeddedAgent(
|
||||
paramsInput: RunEmbeddedAgentParams,
|
||||
): Promise<EmbeddedAgentRunResult> {
|
||||
@@ -805,12 +758,17 @@ async function runEmbeddedAgentInternal(
|
||||
startupStages.mark("runtime-plugins");
|
||||
notifyExecutionPhase("runtime_plugins");
|
||||
|
||||
let { provider, modelId } = resolveInitialEmbeddedRunModel({
|
||||
config: params.config,
|
||||
agentId: workspaceResolution.agentId,
|
||||
provider: params.provider,
|
||||
model: params.model,
|
||||
});
|
||||
const requestedProvider = normalizeOptionalString(params.provider);
|
||||
const requestedModel = normalizeOptionalString(params.model);
|
||||
const configuredDefault =
|
||||
!requestedProvider && !requestedModel
|
||||
? resolveDefaultModelForAgent({
|
||||
cfg: params.config ?? {},
|
||||
agentId: workspaceResolution.agentId,
|
||||
})
|
||||
: undefined;
|
||||
let provider = requestedProvider ?? configuredDefault?.provider ?? DEFAULT_PROVIDER;
|
||||
let modelId = requestedModel ?? configuredDefault?.model ?? DEFAULT_MODEL;
|
||||
const agentDir =
|
||||
params.agentDir ?? resolveAgentDir(params.config ?? {}, workspaceResolution.agentId);
|
||||
const normalizedSessionKey = params.sessionKey?.trim();
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
||||
/**
|
||||
* Handles sessions-yield interruption, persistence, and artifact cleanup.
|
||||
*/
|
||||
import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js";
|
||||
import type { AgentMessage } from "../../runtime/index.js";
|
||||
import { log } from "../logger.js";
|
||||
import { resolveEmbeddedAbortSettleTimeoutMs } from "./attempt.abort-settle-timeout.js";
|
||||
@@ -181,51 +180,47 @@ export function stripSessionsYieldArtifacts(activeSession: {
|
||||
|
||||
const sessionManager = activeSession.sessionManager as
|
||||
| {
|
||||
removeTrailingEntries?: (
|
||||
predicate: (entry: {
|
||||
type?: string;
|
||||
message?: {
|
||||
role?: string;
|
||||
stopReason?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
customType?: string;
|
||||
}) => boolean,
|
||||
options?: {
|
||||
preserveTrailing?: (entry: {
|
||||
type?: string;
|
||||
message?: {
|
||||
role?: string;
|
||||
provider?: string;
|
||||
model?: string;
|
||||
};
|
||||
}) => boolean;
|
||||
},
|
||||
) => number;
|
||||
fileEntries?: Array<{
|
||||
type?: string;
|
||||
id?: string;
|
||||
parentId?: string | null;
|
||||
message?: { role?: string; stopReason?: string };
|
||||
customType?: string;
|
||||
}>;
|
||||
byId?: Map<string, { id: string }>;
|
||||
leafId?: string | null;
|
||||
rewriteFile?: () => void;
|
||||
}
|
||||
| undefined;
|
||||
if (typeof sessionManager?.removeTrailingEntries !== "function") {
|
||||
const fileEntries = sessionManager?.fileEntries;
|
||||
const byId = sessionManager?.byId;
|
||||
if (!fileEntries || !byId) {
|
||||
return;
|
||||
}
|
||||
|
||||
sessionManager.removeTrailingEntries(
|
||||
(entry) => {
|
||||
const isYieldAbortAssistant =
|
||||
entry.type === "message" &&
|
||||
entry.message?.role === "assistant" &&
|
||||
entry.message?.stopReason === "aborted";
|
||||
const isYieldInterruptMessage =
|
||||
entry.type === "custom_message" &&
|
||||
entry.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE;
|
||||
return isYieldAbortAssistant || isYieldInterruptMessage;
|
||||
},
|
||||
{
|
||||
preserveTrailing: (entry) =>
|
||||
entry.type === "custom" ||
|
||||
entry.type === "label" ||
|
||||
entry.type === "session_info" ||
|
||||
(entry.type === "message" && isTranscriptOnlyOpenClawAssistantMessage(entry.message)),
|
||||
},
|
||||
);
|
||||
let changed = false;
|
||||
while (fileEntries.length > 1) {
|
||||
const last = fileEntries.at(-1);
|
||||
if (!last || last.type === "session") {
|
||||
break;
|
||||
}
|
||||
const isYieldAbortAssistant =
|
||||
last.type === "message" &&
|
||||
last.message?.role === "assistant" &&
|
||||
last.message?.stopReason === "aborted";
|
||||
const isYieldInterruptMessage =
|
||||
last.type === "custom_message" && last.customType === SESSIONS_YIELD_INTERRUPT_CUSTOM_TYPE;
|
||||
if (!isYieldAbortAssistant && !isYieldInterruptMessage) {
|
||||
break;
|
||||
}
|
||||
fileEntries.pop();
|
||||
if (last.id) {
|
||||
byId.delete(last.id);
|
||||
}
|
||||
sessionManager.leafId = last.parentId ?? null;
|
||||
changed = true;
|
||||
}
|
||||
if (changed) {
|
||||
sessionManager.rewriteFile?.();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,6 @@ type SessionManagerMocks = {
|
||||
appendCustomEntry: UnknownMock;
|
||||
flushPendingToolResults: UnknownMock;
|
||||
clearPendingToolResults: UnknownMock;
|
||||
removeTrailingEntries: UnknownMock;
|
||||
};
|
||||
type AttemptSpawnWorkspaceHoisted = {
|
||||
spawnSubagentDirectMock: UnknownMock;
|
||||
@@ -209,7 +208,6 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
|
||||
appendCustomEntry: vi.fn(),
|
||||
flushPendingToolResults: vi.fn(),
|
||||
clearPendingToolResults: vi.fn(),
|
||||
removeTrailingEntries: vi.fn(() => 0),
|
||||
};
|
||||
return {
|
||||
spawnSubagentDirectMock,
|
||||
|
||||
@@ -21,7 +21,6 @@ import { resolveQuotaSuspensionEntryMaintenance } from "../../../config/sessions
|
||||
import {
|
||||
bindOwnedSessionTranscriptWrites,
|
||||
type OwnedSessionTranscriptCacheSnapshot,
|
||||
type OwnedSessionTranscriptWriteOptions,
|
||||
withOwnedSessionTranscriptWrites,
|
||||
} from "../../../config/sessions/transcript-write-context.js";
|
||||
import type { SessionEntry } from "../../../config/sessions/types.js";
|
||||
@@ -67,7 +66,6 @@ import {
|
||||
import { getPluginToolMeta } from "../../../plugins/tools.js";
|
||||
import { isSubagentSessionKey } from "../../../routing/session-key.js";
|
||||
import { annotateInterSessionPromptText } from "../../../sessions/input-provenance.js";
|
||||
import { isTranscriptOnlyOpenClawAssistantMessage } from "../../../shared/transcript-only-openclaw-assistant.js";
|
||||
import { resolveSkillsPromptForRun } from "../../../skills/loading/workspace.js";
|
||||
import { resolveEmbeddedRunSkillEntries } from "../../../skills/runtime/embedded-run-entries.js";
|
||||
import {
|
||||
@@ -701,27 +699,42 @@ function removeTrailingMidTurnPrecheckAssistantError(params: {
|
||||
sessionManager: ReturnType<typeof guardSessionManager>;
|
||||
}): void {
|
||||
const messages = params.activeSession.agent.state.messages;
|
||||
const removedActiveError = isMidTurnPrecheckAssistantError(messages.at(-1));
|
||||
if (removedActiveError) {
|
||||
if (isMidTurnPrecheckAssistantError(messages.at(-1))) {
|
||||
params.activeSession.agent.state.messages = messages.slice(0, -1);
|
||||
}
|
||||
|
||||
const removedPersistedError =
|
||||
params.sessionManager.removeTrailingEntries(
|
||||
(entry) => entry.type === "message" && isMidTurnPrecheckAssistantError(entry.message),
|
||||
{
|
||||
preserveTrailing: (entry) =>
|
||||
entry.type === "custom" ||
|
||||
entry.type === "label" ||
|
||||
entry.type === "session_info" ||
|
||||
(entry.type === "message" && isTranscriptOnlyOpenClawAssistantMessage(entry.message)),
|
||||
},
|
||||
) > 0;
|
||||
if (removedActiveError && !removedPersistedError) {
|
||||
log.warn(
|
||||
"[context-overflow-midturn-precheck] removed synthetic assistant error from active session but could not locate matching persisted SessionManager entry",
|
||||
);
|
||||
const mutableSessionManager = params.sessionManager as unknown as {
|
||||
fileEntries?: Array<{
|
||||
type?: string;
|
||||
id?: string;
|
||||
parentId?: string | null;
|
||||
message?: AgentMessage;
|
||||
}>;
|
||||
byId?: Map<string, unknown>;
|
||||
leafId?: string | null;
|
||||
rewriteFile?: () => void;
|
||||
};
|
||||
const lastEntry = mutableSessionManager.fileEntries?.at(-1);
|
||||
if (lastEntry?.type !== "message" || !isMidTurnPrecheckAssistantError(lastEntry.message)) {
|
||||
if (isMidTurnPrecheckAssistantError(params.activeSession.agent.state.messages.at(-1))) {
|
||||
log.warn(
|
||||
"[context-overflow-midturn-precheck] removed synthetic assistant error from active session but could not locate matching persisted SessionManager entry",
|
||||
);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (typeof mutableSessionManager.rewriteFile !== "function") {
|
||||
log.warn(
|
||||
"[context-overflow-midturn-precheck] removed synthetic assistant error from active session but SessionManager rewrite hook is unavailable",
|
||||
);
|
||||
return;
|
||||
}
|
||||
mutableSessionManager.fileEntries?.pop();
|
||||
if (lastEntry.id) {
|
||||
mutableSessionManager.byId?.delete(lastEntry.id);
|
||||
}
|
||||
mutableSessionManager.leafId = lastEntry.parentId ?? null;
|
||||
mutableSessionManager.rewriteFile();
|
||||
}
|
||||
|
||||
function collectAttemptExplicitToolAllowlistSources(params: {
|
||||
@@ -2102,25 +2115,12 @@ export async function runEmbeddedAttempt(
|
||||
timeoutMs: sessionWriteLockOptions.maxHoldMs,
|
||||
signal: params.abortSignal,
|
||||
});
|
||||
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
|
||||
const sessionLockController = await createEmbeddedAttemptSessionLockController({
|
||||
acquireSessionWriteLock,
|
||||
lockOptions: {
|
||||
sessionFile: params.sessionFile,
|
||||
...sessionWriteLockOptions,
|
||||
},
|
||||
mergePromptReleasedSessionEntries: (entries) => {
|
||||
if (!sessionManager) {
|
||||
throw new Error("session manager unavailable during prompt-released entry merge");
|
||||
}
|
||||
return sessionManager.mergePromptReleasedSessionEntries(entries, { persistLeaf: true });
|
||||
},
|
||||
reloadPromptReleasedSessionFile: () => {
|
||||
if (!sessionManager) {
|
||||
throw new Error("session manager unavailable during prompt-released file reload");
|
||||
}
|
||||
sessionManager.setSessionFile(params.sessionFile);
|
||||
},
|
||||
});
|
||||
releaseRetainedSessionLock = () => sessionLockController.dispose();
|
||||
const ownedTranscriptWriteContext = {
|
||||
@@ -2132,7 +2132,7 @@ export async function runEmbeddedAttempt(
|
||||
sessionLockController.publishOwnedSessionFileSnapshot(snapshot),
|
||||
withSessionWriteLock: <T>(
|
||||
operation: () => Promise<T> | T,
|
||||
options?: OwnedSessionTranscriptWriteOptions<T>,
|
||||
options?: { publishOwnedWrite?: boolean },
|
||||
) => sessionLockController.withSessionWriteLock(operation, options),
|
||||
};
|
||||
const withOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T): Promise<T> =>
|
||||
@@ -2141,6 +2141,7 @@ export async function runEmbeddedAttempt(
|
||||
);
|
||||
armExternalAbortSignal();
|
||||
|
||||
let sessionManager: ReturnType<typeof guardSessionManager> | undefined;
|
||||
let session: Awaited<ReturnType<typeof createAgentSession>>["session"] | undefined;
|
||||
let removeToolResultContextGuard: (() => void) | undefined;
|
||||
let trajectoryRecorder: ReturnType<typeof createTrajectoryRuntimeRecorder> | null = null;
|
||||
|
||||
@@ -71,63 +71,6 @@ describe("prepareSessionManagerForRun", () => {
|
||||
expect(await fs.readFile(sessionFile, "utf-8")).toBe("");
|
||||
});
|
||||
|
||||
it("clears the append parent when resetting a real user-only manager", async () => {
|
||||
const sessionFile = await makeTempFile();
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "old-session",
|
||||
timestamp: "2026-05-27T00:00:00.000Z",
|
||||
cwd: "/old/cwd",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "message",
|
||||
id: "old-user",
|
||||
parentId: null,
|
||||
timestamp: "2026-05-27T00:00:01.000Z",
|
||||
message: { role: "user", content: "old prompt" },
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), "/old/cwd");
|
||||
|
||||
await prepareSessionManagerForRun({
|
||||
sessionManager,
|
||||
sessionFile,
|
||||
hadSessionFile: true,
|
||||
sessionId: "new-session",
|
||||
cwd: "/tmp/task-repo",
|
||||
});
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [{ type: "text", text: "response" }],
|
||||
api: "messages",
|
||||
provider: "anthropic",
|
||||
model: "sonnet-4.6",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const entries = (await fs.readFile(sessionFile, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { type: string; parentId?: string | null });
|
||||
expect(entries).toHaveLength(2);
|
||||
expect(entries[1]).toEqual(expect.objectContaining({ type: "message", parentId: null }));
|
||||
});
|
||||
|
||||
it("rewrites forked transcript headers with copied assistant messages to the runtime cwd", async () => {
|
||||
// Forked sessions keep copied assistant context but rewrite the session
|
||||
// header to the child run id and active workspace cwd.
|
||||
@@ -211,85 +154,6 @@ describe("prepareSessionManagerForRun", () => {
|
||||
expect(JSON.parse(assistantLine ?? "{}")).toEqual(assistantEntry);
|
||||
});
|
||||
|
||||
it("preserves a forked empty branch and its opaque append cursor", async () => {
|
||||
const sessionFile = await makeTempFile();
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify({
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "forked-session",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: "/old/cwd",
|
||||
parentSession: "/sessions/parent.jsonl",
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: null,
|
||||
}),
|
||||
JSON.stringify({
|
||||
type: "leaf",
|
||||
id: "empty-leaf",
|
||||
parentId: "plugin-metadata",
|
||||
targetId: null,
|
||||
appendParentId: "plugin-metadata",
|
||||
}),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
const sessionManager = SessionManager.open(sessionFile, path.dirname(sessionFile), "/old/cwd");
|
||||
|
||||
await prepareSessionManagerForRun({
|
||||
sessionManager,
|
||||
sessionFile,
|
||||
hadSessionFile: true,
|
||||
sessionId: "child-session",
|
||||
cwd: "/tmp/task-repo",
|
||||
});
|
||||
|
||||
const userId = sessionManager.appendMessage({
|
||||
role: "user",
|
||||
content: "continued",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
sessionManager.appendMessage({
|
||||
role: "assistant",
|
||||
content: [],
|
||||
api: "responses",
|
||||
provider: "openai",
|
||||
model: "gpt-test",
|
||||
usage: {
|
||||
input: 0,
|
||||
output: 0,
|
||||
cacheRead: 0,
|
||||
cacheWrite: 0,
|
||||
totalTokens: 0,
|
||||
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
|
||||
},
|
||||
stopReason: "stop",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
|
||||
const records = (await fs.readFile(sessionFile, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
expect(records[0]).toMatchObject({
|
||||
type: "session",
|
||||
id: "child-session",
|
||||
cwd: "/tmp/task-repo",
|
||||
parentSession: "/sessions/parent.jsonl",
|
||||
});
|
||||
expect(records.some((record) => record.id === "plugin-metadata")).toBe(true);
|
||||
expect(records.some((record) => record.id === "empty-leaf")).toBe(true);
|
||||
expect(records.find((record) => record.id === userId)).toMatchObject({
|
||||
type: "message",
|
||||
parentId: "plugin-metadata",
|
||||
});
|
||||
});
|
||||
|
||||
it("does not truncate an existing transcript with a corrupted header", async () => {
|
||||
// A corrupt header may still be followed by useful transcript entries; fail
|
||||
// closed instead of truncating unknown persisted user data.
|
||||
|
||||
@@ -5,12 +5,7 @@ import fs from "node:fs/promises";
|
||||
import { serializeJsonlLine, writeJsonlLines } from "../../config/sessions/transcript-jsonl.js";
|
||||
import { invalidateSessionFileRepairCache } from "../session-file-repair.js";
|
||||
|
||||
type SessionHeaderEntry = {
|
||||
type: "session";
|
||||
id?: string;
|
||||
cwd?: string;
|
||||
parentSession?: string;
|
||||
};
|
||||
type SessionHeaderEntry = { type: "session"; id?: string; cwd?: string };
|
||||
type SessionMessageEntry = { type: "message"; message?: { role?: string } };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -63,8 +58,6 @@ export async function prepareSessionManagerForRun(params: {
|
||||
labelsById?: Map<string, unknown>;
|
||||
leafId?: string | null;
|
||||
wasRecoveredFromCorruptHeader?: () => boolean;
|
||||
clearPreservedOpaqueFileEntries?: () => void;
|
||||
getSerializedFileLinesForRewrite?: () => string[];
|
||||
syncSnapshotAfterHeaderRewrite?: (expectedContent?: string) => void;
|
||||
};
|
||||
|
||||
@@ -82,18 +75,14 @@ export async function prepareSessionManagerForRun(params: {
|
||||
}
|
||||
|
||||
if (params.hadSessionFile && header && !hasAssistant) {
|
||||
const preservesForkedBranch =
|
||||
typeof header.parentSession === "string" && header.parentSession.length > 0;
|
||||
if (sm.wasRecoveredFromCorruptHeader?.() || preservesForkedBranch) {
|
||||
// Fork transcripts can intentionally select a user-only or empty branch.
|
||||
// Keep their copied tree so the first run appends at the preserved cursor.
|
||||
if (sm.wasRecoveredFromCorruptHeader?.()) {
|
||||
header.id = params.sessionId;
|
||||
header.cwd = params.cwd;
|
||||
sm.sessionId = params.sessionId;
|
||||
sm.cwd = params.cwd;
|
||||
const content = await writeJsonlLines(
|
||||
params.sessionFile,
|
||||
sm.getSerializedFileLinesForRewrite?.() ?? sm.fileEntries.map(serializeJsonlLine),
|
||||
sm.fileEntries.map(serializeJsonlLine),
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
@@ -112,7 +101,6 @@ export async function prepareSessionManagerForRun(params: {
|
||||
sm.sessionId = params.sessionId;
|
||||
sm.cwd = params.cwd;
|
||||
sm.fileEntries = [header];
|
||||
sm.clearPreservedOpaqueFileEntries?.();
|
||||
sm.byId?.clear?.();
|
||||
sm.labelsById?.clear?.();
|
||||
sm.leafId = null;
|
||||
@@ -132,7 +120,7 @@ export async function prepareSessionManagerForRun(params: {
|
||||
}
|
||||
const content = await writeJsonlLines(
|
||||
params.sessionFile,
|
||||
sm.getSerializedFileLinesForRewrite?.() ?? sm.fileEntries.map(serializeJsonlLine),
|
||||
sm.fileEntries.map(serializeJsonlLine),
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
|
||||
@@ -67,6 +67,7 @@ const DEFAULT_SUFFIX = (truncatedChars: number) =>
|
||||
formatContextLimitTruncationNotice(truncatedChars);
|
||||
const COMPACT_RECOVERY_SUFFIX = (truncatedChars: number) =>
|
||||
`[... ${Math.max(1, Math.floor(truncatedChars))} chars truncated; narrow args]`;
|
||||
export const MIN_TRUNCATED_TEXT_CHARS = MIN_KEEP_CHARS + DEFAULT_SUFFIX(1).length;
|
||||
|
||||
function resolveSuffixFactory(
|
||||
suffix: ToolResultTruncationOptions["suffix"],
|
||||
|
||||
@@ -4,10 +4,7 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
persistTranscriptStateMutation,
|
||||
readTranscriptFileState,
|
||||
} from "./transcript-file-state.js";
|
||||
import { readTranscriptFileState } from "./transcript-file-state.js";
|
||||
import { rewriteTranscriptEntriesInState } from "./transcript-rewrite.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
@@ -463,54 +460,6 @@ describe("readTranscriptFileState", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("canonicalizes opaque append parents before a legacy migration rewrite", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-v1-opaque-parent-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: 1,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: root,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "assistant", content: "legacy active" },
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: "missing-before-migration",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const state = await readTranscriptFileState(sessionFile);
|
||||
const activeLeafId = state.getLeafId();
|
||||
const appended = state.appendMessage({
|
||||
role: "user",
|
||||
content: "continued",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await persistTranscriptStateMutation({
|
||||
sessionFile,
|
||||
state,
|
||||
appendedEntries: [appended],
|
||||
});
|
||||
|
||||
expect(state.migrated).toBe(true);
|
||||
expect(appended.parentId).toBe(activeLeafId);
|
||||
const reopened = await readTranscriptFileState(sessionFile);
|
||||
expect(reopened.getBranch().map((entry) => entry.id)).toEqual([activeLeafId, appended.id]);
|
||||
});
|
||||
|
||||
it("preserves legacy compaction keep indexes across JSON-valid non-object rows", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-v1-compaction-null-row-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
@@ -895,55 +844,6 @@ describe("readTranscriptFileState", () => {
|
||||
expect(state.getBranch().map((entry) => entry.id)).toEqual(["user-1"]);
|
||||
});
|
||||
|
||||
it("breaks cycles between canonical and opaque rows", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-canonical-opaque-cycle-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: root,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "active-entry",
|
||||
parentId: "opaque-cycle",
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "user", content: "kept through cycle" },
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "opaque-cycle",
|
||||
parentId: "active-entry",
|
||||
payload: { source: "plugin" },
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const state = await readTranscriptFileState(sessionFile);
|
||||
|
||||
expect(state.getBranch().map((entry) => ({ id: entry.id, parentId: entry.parentId }))).toEqual([
|
||||
{ id: "active-entry", parentId: null },
|
||||
]);
|
||||
expect(state.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "user", content: "kept through cycle" },
|
||||
]);
|
||||
const appended = state.appendMessage({
|
||||
role: "user",
|
||||
content: "continued",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(appended.parentId).toBe("opaque-cycle");
|
||||
expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-entry", appended.id]);
|
||||
});
|
||||
|
||||
it("drops missing parents reached through rejected rows before rewrite replay", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-rejected-missing-parent-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
@@ -1070,333 +970,6 @@ describe("readTranscriptFileState", () => {
|
||||
).not.toThrow();
|
||||
});
|
||||
|
||||
it("applies leaf controls to active state and marker-linked descendants", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-leaf-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
const header = {
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-05-16T00:00:00.000Z",
|
||||
cwd: root,
|
||||
};
|
||||
const rootEntry = {
|
||||
type: "message",
|
||||
id: "root-user",
|
||||
parentId: null,
|
||||
timestamp: "2026-05-16T00:00:01.000Z",
|
||||
message: { role: "user", content: "root question" },
|
||||
};
|
||||
const abandonedEntry = {
|
||||
type: "message",
|
||||
id: "abandoned-assistant",
|
||||
parentId: rootEntry.id,
|
||||
timestamp: "2026-05-16T00:00:02.000Z",
|
||||
message: { role: "assistant", content: "abandoned answer" },
|
||||
};
|
||||
const leafEntry = {
|
||||
type: "leaf",
|
||||
id: "leaf-1",
|
||||
parentId: abandonedEntry.id,
|
||||
timestamp: "2026-05-16T00:00:03.000Z",
|
||||
targetId: rootEntry.id,
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[header, rootEntry, abandonedEntry, leafEntry]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const selectedState = await readTranscriptFileState(sessionFile);
|
||||
expect(selectedState.getLeafId()).toBe(rootEntry.id);
|
||||
expect(selectedState.getBranch().map((entry) => entry.id)).toEqual([rootEntry.id]);
|
||||
|
||||
const replacementEntry = {
|
||||
type: "message",
|
||||
id: "replacement-assistant",
|
||||
parentId: leafEntry.id,
|
||||
timestamp: "2026-05-16T00:00:04.000Z",
|
||||
message: { role: "assistant", content: "replacement answer" },
|
||||
};
|
||||
await fs.appendFile(sessionFile, `${JSON.stringify(replacementEntry)}\n`, "utf8");
|
||||
|
||||
const reopened = await readTranscriptFileState(sessionFile);
|
||||
expect(reopened.getEntries().find((entry) => entry.id === replacementEntry.id)).toEqual(
|
||||
expect.objectContaining({ parentId: rootEntry.id }),
|
||||
);
|
||||
expect(reopened.getBranch().map((entry) => entry.id)).toEqual([
|
||||
rootEntry.id,
|
||||
replacementEntry.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps parentless canonical ancestry through rewrite replay", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-parentless-leaf-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: root,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "user", content: "question", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
message: { role: "assistant", content: "answer", timestamp: 2 },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "assistant-1",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "assistant-1",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const state = await readTranscriptFileState(sessionFile);
|
||||
expect(state.getBranch().map((entry) => entry.id)).toEqual(["user-1", "assistant-1"]);
|
||||
|
||||
rewriteTranscriptEntriesInState({
|
||||
state,
|
||||
replacements: [
|
||||
{
|
||||
entryId: "user-1",
|
||||
message: { role: "user", content: "rewritten question", timestamp: 3 },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(state.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "user", content: "rewritten question" },
|
||||
{ role: "assistant", content: "answer" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves marked side ancestry without capturing the next active append", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-side-append-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: root,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "assistant", content: "active" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "side-one",
|
||||
parentId: "active-root",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
message: { role: "assistant", content: "first side delivery" },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "first-leaf",
|
||||
parentId: "side-one",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "side-one",
|
||||
appendMode: "side",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "side-two",
|
||||
parentId: "side-one",
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
appendMode: "side",
|
||||
message: { role: "assistant", content: "second side delivery" },
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const state = await readTranscriptFileState(sessionFile);
|
||||
|
||||
expect(state.getBranch("side-two").map((entry) => entry.id)).toEqual([
|
||||
"active-root",
|
||||
"side-one",
|
||||
"side-two",
|
||||
]);
|
||||
expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-root"]);
|
||||
expect(state.getLeafId()).toBe("active-root");
|
||||
expect(state.getAppendParentId()).toBe("side-two");
|
||||
expect(state.getAppendMode()).toBe("side");
|
||||
|
||||
const nextUser = state.appendMessage({
|
||||
role: "user",
|
||||
content: "next question",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
expect(state.getBranch(nextUser.id).map((entry) => entry.id)).toEqual([
|
||||
"active-root",
|
||||
nextUser.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps a terminal leaf control's opaque append parent", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-opaque-append-parent-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: root,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "assistant", content: "active" },
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: null,
|
||||
payload: { source: "plugin" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "side-delivery",
|
||||
parentId: "active-root",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
message: { role: "assistant", content: "side delivery" },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "side-delivery",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "plugin-metadata",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const state = await readTranscriptFileState(sessionFile);
|
||||
const appended = state.appendMessage({
|
||||
role: "user",
|
||||
content: "continued",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await persistTranscriptStateMutation({
|
||||
sessionFile,
|
||||
state,
|
||||
appendedEntries: [appended],
|
||||
});
|
||||
const persisted = (await fs.readFile(sessionFile, "utf8"))
|
||||
.trim()
|
||||
.split(/\r?\n/)
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
|
||||
expect(state.getLeafId()).toBe(appended.id);
|
||||
expect(appended.parentId).toBe("plugin-metadata");
|
||||
expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-root", appended.id]);
|
||||
expect(persisted.at(-1)).toMatchObject({ id: appended.id, parentId: "plugin-metadata" });
|
||||
});
|
||||
|
||||
it("ignores leaf controls with dangling target or append references", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-invalid-leaf-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-1",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: root,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "assistant", content: "active" },
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: "active-root",
|
||||
payload: { source: "plugin" },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "missing-target",
|
||||
parentId: "plugin-metadata",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
targetId: "missing",
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "missing-append",
|
||||
parentId: "missing-target",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "missing",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const state = await readTranscriptFileState(sessionFile);
|
||||
const appended = state.appendMessage({
|
||||
role: "user",
|
||||
content: "continued",
|
||||
timestamp: Date.now(),
|
||||
});
|
||||
await persistTranscriptStateMutation({
|
||||
sessionFile,
|
||||
state,
|
||||
appendedEntries: [appended],
|
||||
});
|
||||
|
||||
expect(appended.parentId).toBe("plugin-metadata");
|
||||
expect(state.getBranch().map((entry) => entry.id)).toEqual(["active-root", appended.id]);
|
||||
const reopened = await readTranscriptFileState(sessionFile);
|
||||
expect(reopened.getLeafId()).toBe(appended.id);
|
||||
expect(reopened.getBranch().map((entry) => entry.id)).toEqual(["active-root", appended.id]);
|
||||
});
|
||||
|
||||
it("keeps legacy roots that are missing tree metadata", async () => {
|
||||
const root = await makeRoot("openclaw-transcript-state-legacy-root-");
|
||||
const sessionFile = path.join(root, "session.jsonl");
|
||||
|
||||
@@ -4,7 +4,6 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { isSessionTranscriptSideAppendEntry } from "../../config/sessions/transcript-tree.js";
|
||||
import { CURRENT_SESSION_VERSION } from "../../config/sessions/version.js";
|
||||
import { appendRegularFile } from "../../infra/fs-safe.js";
|
||||
import { privateFileStore } from "../../infra/private-file-store.js";
|
||||
@@ -30,18 +29,6 @@ type SessionInfoEntry = Extract<SessionEntry, { type: "session_info" }>;
|
||||
type SessionMessageEntry = Extract<SessionEntry, { type: "message" }>;
|
||||
type ThinkingLevelChangeEntry = Extract<SessionEntry, { type: "thinking_level_change" }>;
|
||||
|
||||
export type TranscriptLeafControlEntry = {
|
||||
type: "leaf";
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
timestamp: string;
|
||||
targetId: string | null;
|
||||
appendParentId?: string | null;
|
||||
appendMode?: "side";
|
||||
};
|
||||
|
||||
export type TranscriptPersistedEntry = SessionEntry | TranscriptLeafControlEntry;
|
||||
|
||||
const sessionEntryTypes = new Set<string>([
|
||||
"branch_summary",
|
||||
"compaction",
|
||||
@@ -289,73 +276,16 @@ function isSessionEntry(entry: FileEntry): entry is SessionEntry {
|
||||
return false;
|
||||
}
|
||||
|
||||
function parseLeafControlEntry(entry: unknown):
|
||||
| {
|
||||
id: string;
|
||||
parentId: string | null;
|
||||
targetId: string | null;
|
||||
appendParentId?: string | null;
|
||||
appendMode?: "side";
|
||||
}
|
||||
| undefined {
|
||||
if (!isRecord(entry) || entry.type !== "leaf") {
|
||||
return undefined;
|
||||
}
|
||||
const candidate = entry as {
|
||||
id?: unknown;
|
||||
parentId?: unknown;
|
||||
targetId?: unknown;
|
||||
appendParentId?: unknown;
|
||||
appendMode?: unknown;
|
||||
timestamp?: unknown;
|
||||
};
|
||||
if (
|
||||
!isString(candidate.id) ||
|
||||
(candidate.parentId !== undefined &&
|
||||
candidate.parentId !== null &&
|
||||
!isString(candidate.parentId)) ||
|
||||
(candidate.timestamp !== undefined && !isString(candidate.timestamp)) ||
|
||||
(candidate.targetId !== null && typeof candidate.targetId !== "string") ||
|
||||
(candidate.appendParentId !== undefined &&
|
||||
candidate.appendParentId !== null &&
|
||||
typeof candidate.appendParentId !== "string") ||
|
||||
(candidate.appendMode !== undefined && candidate.appendMode !== "side")
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
id: candidate.id,
|
||||
parentId: candidate.parentId ?? null,
|
||||
targetId: candidate.targetId,
|
||||
...(candidate.appendParentId !== undefined ? { appendParentId: candidate.appendParentId } : {}),
|
||||
...(candidate.appendMode === "side" ? { appendMode: candidate.appendMode } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
type ReadableSessionState = {
|
||||
entries: SessionEntry[];
|
||||
leafId: string | null;
|
||||
appendParentId: string | null;
|
||||
appendMode?: "side";
|
||||
opaqueParentsById: Map<string, string | null>;
|
||||
logicalParentsById: Map<string, string | null>;
|
||||
};
|
||||
|
||||
// Keep every readable entry while repairing links through rejected rows. This
|
||||
// preserves usable branches from partially written or migrated transcripts.
|
||||
function readableSessionState(fileEntries: FileEntry[]): ReadableSessionState {
|
||||
function readableSessionEntries(fileEntries: FileEntry[]): SessionEntry[] {
|
||||
const entries: SessionEntry[] = [];
|
||||
const acceptedIds = new Set<string>();
|
||||
const acceptedEntryById = new Map<string, SessionEntry>();
|
||||
const rejectedIds = new Set<string>();
|
||||
const rejectedParentById = new Map<string, string | null>();
|
||||
const logicalParentsById = new Map<string, string | null>();
|
||||
const invalidLeafIds = new Set<string>();
|
||||
const firstReadableDescendantByRejectedId = new Map<string, string>();
|
||||
const rejectedAncestorsByAcceptedId = new Map<string, string[]>();
|
||||
let effectiveLeafId: string | null = null;
|
||||
let effectiveAppendParentId: string | null = null;
|
||||
let effectiveAppendMode: "side" | undefined;
|
||||
const acceptedPath = (leafId: string | null | undefined): SessionEntry[] => {
|
||||
const pathLocal: SessionEntry[] = [];
|
||||
let id = leafId ?? null;
|
||||
@@ -454,62 +384,13 @@ function readableSessionState(fileEntries: FileEntry[]): ReadableSessionState {
|
||||
if (!isRecord(rawEntry)) {
|
||||
continue;
|
||||
}
|
||||
const rawRecord = rawEntry as unknown as Record<string, unknown>;
|
||||
const entry = rawEntry as FileEntry;
|
||||
const id = rawRecord.id;
|
||||
const rawType = rawRecord.type;
|
||||
const rawParentId = rawRecord.parentId;
|
||||
const leafEntry = parseLeafControlEntry(rawRecord);
|
||||
if (leafEntry) {
|
||||
rejectedIds.add(leafEntry.id);
|
||||
const targetIsKnown =
|
||||
leafEntry.targetId === null ||
|
||||
acceptedIds.has(leafEntry.targetId) ||
|
||||
(rejectedParentById.has(leafEntry.targetId) && !invalidLeafIds.has(leafEntry.targetId));
|
||||
const appendParentIsKnown =
|
||||
leafEntry.appendParentId === undefined ||
|
||||
leafEntry.appendParentId === null ||
|
||||
acceptedIds.has(leafEntry.appendParentId) ||
|
||||
(rejectedParentById.has(leafEntry.appendParentId) &&
|
||||
!invalidLeafIds.has(leafEntry.appendParentId));
|
||||
if (!targetIsKnown || !appendParentIsKnown) {
|
||||
// Ignore corrupt navigation state, but keep the marker transparent so
|
||||
// descendants can still repair through the serialized raw branch.
|
||||
invalidLeafIds.add(leafEntry.id);
|
||||
rejectedParentById.set(leafEntry.id, leafEntry.parentId);
|
||||
continue;
|
||||
}
|
||||
rejectedParentById.set(leafEntry.id, leafEntry.targetId);
|
||||
const resolvedTargetId = resolveRejectedParent(leafEntry.targetId);
|
||||
effectiveLeafId =
|
||||
resolvedTargetId !== null && acceptedIds.has(resolvedTargetId) ? resolvedTargetId : null;
|
||||
effectiveAppendParentId =
|
||||
leafEntry.appendParentId === undefined ? effectiveLeafId : leafEntry.appendParentId;
|
||||
effectiveAppendMode = leafEntry.appendMode;
|
||||
continue;
|
||||
}
|
||||
if (rawType === "leaf") {
|
||||
if (isString(id)) {
|
||||
rejectedIds.add(id);
|
||||
invalidLeafIds.add(id);
|
||||
rejectedParentById.set(id, isString(rawParentId) ? rawParentId : null);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const id = rawEntry.id;
|
||||
if (!isSessionEntry(entry)) {
|
||||
if (isString(id)) {
|
||||
rejectedIds.add(id);
|
||||
rejectedParentById.set(id, isString(rawParentId) ? rawParentId : null);
|
||||
const isParentLinkedOpaque =
|
||||
typeof rawType === "string" &&
|
||||
rawType !== "session" &&
|
||||
!id.startsWith("__openclaw_invalid_jsonl_slot_") &&
|
||||
!sessionEntryTypes.has(rawType) &&
|
||||
Object.hasOwn(rawRecord, "parentId") &&
|
||||
(rawParentId === null || isString(rawParentId));
|
||||
if (isParentLinkedOpaque) {
|
||||
effectiveAppendParentId = id;
|
||||
}
|
||||
const parentId = rawEntry.parentId;
|
||||
rejectedParentById.set(id, isString(parentId) ? parentId : null);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -521,35 +402,12 @@ function readableSessionState(fileEntries: FileEntry[]): ReadableSessionState {
|
||||
if (acceptedIds.has(entry.id)) {
|
||||
continue;
|
||||
}
|
||||
const hasSerializedParent = Object.hasOwn(rawRecord, "parentId");
|
||||
if (
|
||||
!hasSerializedParent ||
|
||||
(!isSessionTranscriptSideAppendEntry(rawRecord) &&
|
||||
entry.parentId === effectiveAppendParentId &&
|
||||
effectiveLeafId !== effectiveAppendParentId)
|
||||
) {
|
||||
logicalParentsById.set(entry.id, effectiveLeafId);
|
||||
}
|
||||
const repaired = repairEntryLinks(entry);
|
||||
entries.push(repaired);
|
||||
acceptedIds.add(repaired.id);
|
||||
acceptedEntryById.set(repaired.id, repaired);
|
||||
effectiveAppendParentId = repaired.id;
|
||||
if (isSessionTranscriptSideAppendEntry(rawRecord)) {
|
||||
effectiveAppendMode = "side";
|
||||
} else {
|
||||
effectiveLeafId = repaired.id;
|
||||
effectiveAppendMode = undefined;
|
||||
}
|
||||
}
|
||||
return {
|
||||
entries,
|
||||
leafId: effectiveLeafId,
|
||||
appendParentId: effectiveAppendParentId,
|
||||
...(effectiveAppendMode ? { appendMode: effectiveAppendMode } : {}),
|
||||
opaqueParentsById: rejectedParentById,
|
||||
logicalParentsById,
|
||||
};
|
||||
return entries;
|
||||
}
|
||||
|
||||
function sessionHeaderVersion(header: SessionHeader | null): number {
|
||||
@@ -566,7 +424,7 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
function serializeTranscriptFileEntries(entries: readonly unknown[]): string {
|
||||
function serializeTranscriptFileEntries(entries: FileEntry[]): string {
|
||||
return `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
||||
}
|
||||
|
||||
@@ -590,58 +448,27 @@ export class TranscriptFileState {
|
||||
private readonly byId = new Map<string, SessionEntry>();
|
||||
private readonly labelsById = new Map<string, string>();
|
||||
private readonly labelTimestampsById = new Map<string, string>();
|
||||
private readonly opaqueParentsById = new Map<string, string | null>();
|
||||
private readonly logicalParentsById = new Map<string, string | null>();
|
||||
private leafId: string | null = null;
|
||||
private appendParentId: string | null = null;
|
||||
private appendMode: "side" | undefined;
|
||||
|
||||
constructor(params: {
|
||||
header: SessionHeader | null;
|
||||
entries: SessionEntry[];
|
||||
leafId?: string | null;
|
||||
appendParentId?: string | null;
|
||||
appendMode?: "side";
|
||||
opaqueParentsById?: ReadonlyMap<string, string | null>;
|
||||
logicalParentsById?: ReadonlyMap<string, string | null>;
|
||||
migrated?: boolean;
|
||||
}) {
|
||||
this.header = params.header;
|
||||
this.entries = [...params.entries];
|
||||
this.migrated = params.migrated === true;
|
||||
for (const [id, parentId] of params.opaqueParentsById ?? []) {
|
||||
this.opaqueParentsById.set(id, parentId);
|
||||
}
|
||||
for (const [id, parentId] of params.logicalParentsById ?? []) {
|
||||
this.logicalParentsById.set(id, parentId);
|
||||
}
|
||||
this.rebuildIndex(params.leafId, params.appendParentId);
|
||||
this.appendMode = params.appendMode;
|
||||
this.rebuildIndex();
|
||||
}
|
||||
|
||||
private resolveCanonicalParentId(parentId: string | null): string | null {
|
||||
const seen = new Set<string>();
|
||||
let currentId = parentId;
|
||||
while (currentId !== null && this.opaqueParentsById.has(currentId)) {
|
||||
if (seen.has(currentId)) {
|
||||
return null;
|
||||
}
|
||||
seen.add(currentId);
|
||||
currentId = this.opaqueParentsById.get(currentId) ?? null;
|
||||
}
|
||||
return currentId;
|
||||
}
|
||||
|
||||
private rebuildIndex(leafId?: string | null, appendParentId?: string | null): void {
|
||||
private rebuildIndex(): void {
|
||||
this.byId.clear();
|
||||
this.labelsById.clear();
|
||||
this.labelTimestampsById.clear();
|
||||
this.leafId = null;
|
||||
this.appendParentId = null;
|
||||
for (const entry of this.entries) {
|
||||
this.byId.set(entry.id, entry);
|
||||
this.leafId = entry.id;
|
||||
this.appendParentId = entry.id;
|
||||
if (entry.type === "label") {
|
||||
if (entry.label) {
|
||||
this.labelsById.set(entry.targetId, entry.label);
|
||||
@@ -652,14 +479,6 @@ export class TranscriptFileState {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (leafId !== undefined) {
|
||||
this.leafId = leafId;
|
||||
}
|
||||
if (appendParentId !== undefined) {
|
||||
this.appendParentId = appendParentId;
|
||||
} else if (leafId !== undefined) {
|
||||
this.appendParentId = leafId;
|
||||
}
|
||||
}
|
||||
|
||||
getCwd(): string {
|
||||
@@ -678,14 +497,6 @@ export class TranscriptFileState {
|
||||
return this.leafId;
|
||||
}
|
||||
|
||||
getAppendParentId(): string | null {
|
||||
return this.appendParentId;
|
||||
}
|
||||
|
||||
getAppendMode(): "side" | undefined {
|
||||
return this.appendMode;
|
||||
}
|
||||
|
||||
getLeafEntry(): SessionEntry | undefined {
|
||||
return this.leafId ? this.byId.get(this.leafId) : undefined;
|
||||
}
|
||||
@@ -696,34 +507,17 @@ export class TranscriptFileState {
|
||||
|
||||
getBranch(fromId?: string): SessionEntry[] {
|
||||
const branch: SessionEntry[] = [];
|
||||
const seen = new Set<string>();
|
||||
let currentId = fromId ?? this.leafId;
|
||||
while (currentId && !seen.has(currentId)) {
|
||||
const current = this.byId.get(currentId);
|
||||
if (!current) {
|
||||
break;
|
||||
}
|
||||
seen.add(current.id);
|
||||
const resolvedParentId = this.logicalParentsById.has(current.id)
|
||||
? (this.logicalParentsById.get(current.id) ?? null)
|
||||
: this.resolveCanonicalParentId(current.parentId);
|
||||
const parentId =
|
||||
resolvedParentId === current.id || (resolvedParentId && seen.has(resolvedParentId))
|
||||
? null
|
||||
: resolvedParentId;
|
||||
branch.push(
|
||||
parentId === current.parentId ? current : ({ ...current, parentId } as SessionEntry),
|
||||
);
|
||||
currentId = parentId;
|
||||
let current = (fromId ?? this.leafId) ? this.byId.get((fromId ?? this.leafId)!) : undefined;
|
||||
while (current) {
|
||||
branch.push(current);
|
||||
current = current.parentId ? this.byId.get(current.parentId) : undefined;
|
||||
}
|
||||
branch.reverse();
|
||||
return branch;
|
||||
}
|
||||
|
||||
buildSessionContext(): SessionContext {
|
||||
const entries = this.getBranch();
|
||||
const leafId = entries.at(-1)?.id ?? null;
|
||||
return buildSessionContext(entries, leafId, new Map(entries.map((entry) => [entry.id, entry])));
|
||||
return buildSessionContext(this.entries, this.leafId, this.byId);
|
||||
}
|
||||
|
||||
/** Move the active leaf to an existing entry without appending a row. */
|
||||
@@ -732,22 +526,18 @@ export class TranscriptFileState {
|
||||
throw new Error(`Entry ${branchFromId} not found`);
|
||||
}
|
||||
this.leafId = branchFromId;
|
||||
this.appendParentId = branchFromId;
|
||||
this.appendMode = undefined;
|
||||
}
|
||||
|
||||
/** Clear the active leaf so the next append starts a root branch. */
|
||||
resetLeaf(): void {
|
||||
this.leafId = null;
|
||||
this.appendParentId = null;
|
||||
this.appendMode = undefined;
|
||||
}
|
||||
|
||||
appendMessage(message: SessionMessageEntry["message"]): SessionMessageEntry {
|
||||
return this.appendEntry({
|
||||
type: "message",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
});
|
||||
@@ -757,7 +547,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "thinking_level_change",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
});
|
||||
@@ -767,7 +557,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "model_change",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
@@ -784,7 +574,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "compaction",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
@@ -800,7 +590,7 @@ export class TranscriptFileState {
|
||||
customType,
|
||||
data,
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -809,7 +599,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "session_info",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
name: name.trim(),
|
||||
});
|
||||
@@ -828,7 +618,7 @@ export class TranscriptFileState {
|
||||
display,
|
||||
details,
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -840,7 +630,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "label",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.appendParentId,
|
||||
parentId: this.leafId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId,
|
||||
label,
|
||||
@@ -857,7 +647,6 @@ export class TranscriptFileState {
|
||||
throw new Error(`Entry ${branchFromId} not found`);
|
||||
}
|
||||
this.leafId = branchFromId;
|
||||
this.appendParentId = branchFromId;
|
||||
return this.appendEntry({
|
||||
type: "branch_summary",
|
||||
id: generateEntryId(this.byId),
|
||||
@@ -870,58 +659,10 @@ export class TranscriptFileState {
|
||||
});
|
||||
}
|
||||
|
||||
appendLeafControl(params: {
|
||||
targetId: string | null;
|
||||
appendParentId: string | null;
|
||||
appendMode?: "side";
|
||||
}): TranscriptLeafControlEntry {
|
||||
if (params.targetId !== null && !this.byId.has(params.targetId)) {
|
||||
throw new Error(`Entry ${params.targetId} not found`);
|
||||
}
|
||||
if (
|
||||
params.appendParentId !== null &&
|
||||
!this.byId.has(params.appendParentId) &&
|
||||
!this.opaqueParentsById.has(params.appendParentId)
|
||||
) {
|
||||
throw new Error(`Entry ${params.appendParentId} not found`);
|
||||
}
|
||||
const entry: TranscriptLeafControlEntry = {
|
||||
type: "leaf",
|
||||
id: generateEntryId({
|
||||
has: (id) => this.byId.has(id) || this.opaqueParentsById.has(id),
|
||||
}),
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: params.targetId,
|
||||
...(params.appendParentId !== params.targetId
|
||||
? { appendParentId: params.appendParentId }
|
||||
: {}),
|
||||
...(params.appendMode ? { appendMode: params.appendMode } : {}),
|
||||
};
|
||||
this.opaqueParentsById.set(entry.id, params.targetId);
|
||||
this.leafId = params.targetId;
|
||||
this.appendParentId = params.appendParentId;
|
||||
this.appendMode = params.appendMode;
|
||||
return entry;
|
||||
}
|
||||
|
||||
private appendEntry<T extends SessionEntry>(entry: T): T {
|
||||
if (
|
||||
!isSessionTranscriptSideAppendEntry(entry) &&
|
||||
entry.parentId === this.appendParentId &&
|
||||
this.leafId !== this.appendParentId
|
||||
) {
|
||||
this.logicalParentsById.set(entry.id, this.leafId);
|
||||
}
|
||||
this.entries.push(entry);
|
||||
this.byId.set(entry.id, entry);
|
||||
this.appendParentId = entry.id;
|
||||
if (isSessionTranscriptSideAppendEntry(entry)) {
|
||||
this.appendMode = "side";
|
||||
} else {
|
||||
this.leafId = entry.id;
|
||||
this.appendMode = undefined;
|
||||
}
|
||||
this.leafId = entry.id;
|
||||
if (entry.type === "label") {
|
||||
if (entry.label) {
|
||||
this.labelsById.set(entry.targetId, entry.label);
|
||||
@@ -946,23 +687,14 @@ export async function readTranscriptFileState(sessionFile: string): Promise<Tran
|
||||
migrateSessionEntries(fileEntries);
|
||||
const header =
|
||||
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
|
||||
const readable = readableSessionState(fileEntries);
|
||||
return new TranscriptFileState({
|
||||
header,
|
||||
entries: readable.entries,
|
||||
leafId: readable.leafId,
|
||||
appendParentId: migrated ? readable.leafId : readable.appendParentId,
|
||||
...(!migrated && readable.appendMode ? { appendMode: readable.appendMode } : {}),
|
||||
opaqueParentsById: readable.opaqueParentsById,
|
||||
logicalParentsById: readable.logicalParentsById,
|
||||
migrated,
|
||||
});
|
||||
const entries = readableSessionEntries(fileEntries);
|
||||
return new TranscriptFileState({ header, entries, migrated });
|
||||
}
|
||||
|
||||
/** Rewrite the full transcript through the private-file store. */
|
||||
export async function writeTranscriptFileAtomic(
|
||||
filePath: string,
|
||||
entries: Array<SessionHeader | TranscriptPersistedEntry>,
|
||||
entries: Array<SessionHeader | SessionEntry>,
|
||||
): Promise<void> {
|
||||
await privateFileStore(path.dirname(filePath)).writeText(
|
||||
path.basename(filePath),
|
||||
@@ -974,19 +706,15 @@ export async function writeTranscriptFileAtomic(
|
||||
export async function persistTranscriptStateMutation(params: {
|
||||
sessionFile: string;
|
||||
state: TranscriptFileState;
|
||||
appendedEntries: TranscriptPersistedEntry[];
|
||||
appendedEntries: SessionEntry[];
|
||||
}): Promise<void> {
|
||||
if (params.appendedEntries.length === 0 && !params.state.migrated) {
|
||||
return;
|
||||
}
|
||||
if (params.state.migrated) {
|
||||
const appendedLeafControls = params.appendedEntries.filter(
|
||||
(entry): entry is TranscriptLeafControlEntry => entry.type === "leaf",
|
||||
);
|
||||
await writeTranscriptFileAtomic(params.sessionFile, [
|
||||
...(params.state.header ? [params.state.header] : []),
|
||||
...params.state.entries,
|
||||
...appendedLeafControls,
|
||||
]);
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -397,185 +397,6 @@ describe("rewriteTranscriptEntriesInSessionFile", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("rewrites a guarded side branch and restores the active navigation state", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-side-"));
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-side-rewrite",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: dir,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "user", content: "active root", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "side-mirror",
|
||||
parentId: "active-root",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: createTextContent("source reply before rewrite"),
|
||||
timestamp: 2,
|
||||
},
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "side-mirror",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-root",
|
||||
appendParentId: "side-mirror",
|
||||
appendMode: "side",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const result = await rewriteTranscriptEntriesInSessionFile({
|
||||
sessionFile,
|
||||
sessionKey: "agent:main:test",
|
||||
request: {
|
||||
allowedRewriteSuffixEntryIds: ["side-mirror"],
|
||||
replacements: [
|
||||
{
|
||||
entryId: "side-mirror",
|
||||
message: asAppendMessage({
|
||||
role: "assistant",
|
||||
content: createTextContent("source reply after rewrite"),
|
||||
timestamp: 2,
|
||||
}) as AgentMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({ changed: true, rewrittenEntries: 1 });
|
||||
const records = (await fs.readFile(sessionFile, "utf-8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as {
|
||||
type?: string;
|
||||
id?: string;
|
||||
parentId?: string | null;
|
||||
targetId?: string | null;
|
||||
appendParentId?: string | null;
|
||||
appendMode?: "side";
|
||||
message?: AgentMessage;
|
||||
},
|
||||
);
|
||||
const rewrittenSideEntry = records.findLast(
|
||||
(entry) =>
|
||||
entry.type === "message" &&
|
||||
JSON.stringify(entry.message).includes("source reply after rewrite"),
|
||||
);
|
||||
expect(rewrittenSideEntry).toMatchObject({ parentId: "active-root" });
|
||||
expect(records.at(-1)).toMatchObject({
|
||||
type: "leaf",
|
||||
parentId: rewrittenSideEntry?.id,
|
||||
targetId: "active-root",
|
||||
appendParentId: "side-mirror",
|
||||
appendMode: "side",
|
||||
});
|
||||
|
||||
const reopened = SessionManager.open(sessionFile, dir, dir);
|
||||
expect(getBranchMessages(reopened).map(getMessageContent)).toEqual(["active root"]);
|
||||
const nextId = reopened.appendMessage(
|
||||
asAppendMessage({ role: "user", content: "active continuation", timestamp: 3 }),
|
||||
);
|
||||
expect(reopened.getEntry(nextId)).toMatchObject({ parentId: "active-root" });
|
||||
expect(reopened.getEntry(nextId)).not.toHaveProperty("appendMode");
|
||||
});
|
||||
|
||||
it("rejects a rewrite batch split across active and side branches", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-mixed-"));
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const records = [
|
||||
{
|
||||
type: "session",
|
||||
version: 3,
|
||||
id: "session-mixed-rewrite",
|
||||
timestamp: "2026-06-15T00:00:00.000Z",
|
||||
cwd: dir,
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "user", content: "root", timestamp: 1 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "active-mirror",
|
||||
parentId: "root",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
message: { role: "assistant", content: createTextContent("active"), timestamp: 2 },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "side-mirror",
|
||||
parentId: "root",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
message: { role: "assistant", content: createTextContent("side"), timestamp: 3 },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "side-mirror",
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
targetId: "active-mirror",
|
||||
},
|
||||
];
|
||||
const original = records.map((entry) => JSON.stringify(entry)).join("\n") + "\n";
|
||||
await fs.writeFile(sessionFile, original, "utf-8");
|
||||
|
||||
const result = await rewriteTranscriptEntriesInSessionFile({
|
||||
sessionFile,
|
||||
sessionKey: "agent:main:test",
|
||||
request: {
|
||||
allowedRewriteSuffixEntryIds: ["active-mirror", "side-mirror"],
|
||||
replacements: [
|
||||
{
|
||||
entryId: "active-mirror",
|
||||
message: asAppendMessage({
|
||||
role: "assistant",
|
||||
content: createTextContent("active rewritten"),
|
||||
timestamp: 2,
|
||||
}) as AgentMessage,
|
||||
},
|
||||
{
|
||||
entryId: "side-mirror",
|
||||
message: asAppendMessage({
|
||||
role: "assistant",
|
||||
content: createTextContent("side rewritten"),
|
||||
timestamp: 3,
|
||||
}) as AgentMessage,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
changed: false,
|
||||
reason: "rewrite targets span multiple branches",
|
||||
});
|
||||
expect(await fs.readFile(sessionFile, "utf-8")).toBe(original);
|
||||
});
|
||||
|
||||
it("emits transcript updates when the active branch changes without opening a manager", async () => {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-transcript-rewrite-"));
|
||||
const sessionManager = SessionManager.create(dir, dir);
|
||||
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
persistTranscriptStateMutation,
|
||||
readTranscriptFileState,
|
||||
type TranscriptFileState,
|
||||
type TranscriptPersistedEntry,
|
||||
} from "./transcript-file-state.js";
|
||||
import {
|
||||
persistRuntimeTranscriptStateMutation,
|
||||
@@ -262,7 +261,7 @@ export function rewriteTranscriptEntriesInState(params: {
|
||||
state: TranscriptFileState;
|
||||
replacements: TranscriptRewriteReplacement[];
|
||||
allowedRewriteSuffixEntryIds?: string[];
|
||||
}): TranscriptRewriteResult & { appendedEntries: TranscriptPersistedEntry[] } {
|
||||
}): TranscriptRewriteResult & { appendedEntries: SessionBranchEntry[] } {
|
||||
const replacementsById = new Map(
|
||||
params.replacements
|
||||
.filter((replacement) => replacement.entryId.trim().length > 0)
|
||||
@@ -278,58 +277,7 @@ export function rewriteTranscriptEntriesInState(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const originalLeafId = params.state.getLeafId();
|
||||
const originalAppendParentId = params.state.getAppendParentId();
|
||||
const originalAppendMode = params.state.getAppendMode();
|
||||
const activeBranch = params.state.getBranch();
|
||||
const allEntries = params.state.getEntries();
|
||||
let branch = activeBranch;
|
||||
let restoreOriginalNavigation = false;
|
||||
const replacementIdsOnBranch = (candidate: readonly SessionBranchEntry[]): Set<string> =>
|
||||
new Set(
|
||||
candidate
|
||||
.filter((entry) => entry.type === "message" && replacementsById.has(entry.id))
|
||||
.map((entry) => entry.id),
|
||||
);
|
||||
const activeReplacementIds = replacementIdsOnBranch(activeBranch);
|
||||
if (activeReplacementIds.size > 0 && activeReplacementIds.size < replacementsById.size) {
|
||||
return {
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
reason: "rewrite targets span multiple branches",
|
||||
appendedEntries: [],
|
||||
};
|
||||
}
|
||||
const activeBranchHasEveryReplacement = activeReplacementIds.size === replacementsById.size;
|
||||
if (!activeBranchHasEveryReplacement && params.allowedRewriteSuffixEntryIds) {
|
||||
const allowedIds = new Set(params.allowedRewriteSuffixEntryIds);
|
||||
const sideBranch = allEntries
|
||||
.toReversed()
|
||||
.filter((entry) => allowedIds.has(entry.id))
|
||||
.map((entry) => params.state.getBranch(entry.id))
|
||||
.find((candidate) => replacementIdsOnBranch(candidate).size === replacementsById.size);
|
||||
if (sideBranch) {
|
||||
branch = sideBranch;
|
||||
restoreOriginalNavigation = true;
|
||||
}
|
||||
}
|
||||
if (
|
||||
!activeBranchHasEveryReplacement &&
|
||||
!restoreOriginalNavigation &&
|
||||
activeReplacementIds.size === 0 &&
|
||||
params.replacements.some((replacement) =>
|
||||
allEntries.some((entry) => entry.id === replacement.entryId),
|
||||
)
|
||||
) {
|
||||
return {
|
||||
changed: false,
|
||||
bytesFreed: 0,
|
||||
rewrittenEntries: 0,
|
||||
reason: "rewrite targets span multiple branches",
|
||||
appendedEntries: [],
|
||||
};
|
||||
}
|
||||
const branch = params.state.getBranch();
|
||||
if (branch.length === 0) {
|
||||
return {
|
||||
changed: false,
|
||||
@@ -403,7 +351,7 @@ export function rewriteTranscriptEntriesInState(params: {
|
||||
params.state.branch(firstMatchedEntry.parentId);
|
||||
}
|
||||
|
||||
const appendedEntries: TranscriptPersistedEntry[] = [];
|
||||
const appendedEntries: SessionBranchEntry[] = [];
|
||||
const rewrittenEntryIds = new Map<string, string>();
|
||||
for (let index = matchedIndices[0]; index < branch.length; index++) {
|
||||
const entry = branch[index];
|
||||
@@ -419,15 +367,6 @@ export function rewriteTranscriptEntriesInState(params: {
|
||||
rewrittenEntryIds.set(entry.id, newEntry.id);
|
||||
appendedEntries.push(newEntry);
|
||||
}
|
||||
if (restoreOriginalNavigation) {
|
||||
appendedEntries.push(
|
||||
params.state.appendLeafControl({
|
||||
targetId: originalLeafId,
|
||||
appendParentId: originalAppendParentId,
|
||||
...(originalAppendMode ? { appendMode: originalAppendMode } : {}),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
changed: true,
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
persistTranscriptStateMutation,
|
||||
readTranscriptFileState,
|
||||
type TranscriptFileState,
|
||||
type TranscriptPersistedEntry,
|
||||
writeTranscriptFileAtomic,
|
||||
} from "./transcript-file-state.js";
|
||||
|
||||
@@ -61,7 +60,7 @@ export async function readRuntimeTranscriptState(
|
||||
* Persists an append or migration rewrite for a resolved runtime transcript.
|
||||
*/
|
||||
export async function persistRuntimeTranscriptStateMutation(params: {
|
||||
appendedEntries: TranscriptPersistedEntry[];
|
||||
appendedEntries: SessionEntry[];
|
||||
state: TranscriptFileState;
|
||||
target: RuntimeTranscriptTarget;
|
||||
}): Promise<void> {
|
||||
|
||||
@@ -1,164 +0,0 @@
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { getMatchingMessagingToolReplyTargets } from "../auto-reply/reply/reply-payloads-dedupe.js";
|
||||
import { setActivePluginRegistry } from "../plugins/runtime.js";
|
||||
import { createChannelTestPluginBase, createTestRegistry } from "../test-utils/channel-plugins.js";
|
||||
import {
|
||||
extractMessagingToolSend,
|
||||
extractMessagingToolSendResult,
|
||||
} from "./embedded-agent-subscribe.tools.js";
|
||||
|
||||
const PARTIAL_RESULT_PROVIDER = "partialthreadprovider";
|
||||
|
||||
function createPartialResultPlugin(): unknown {
|
||||
return {
|
||||
...createChannelTestPluginBase({ id: PARTIAL_RESULT_PROVIDER }),
|
||||
actions: {
|
||||
extractToolSend: ({ args }: { args: Record<string, unknown> }) =>
|
||||
args.action === "send" && typeof args.to === "string"
|
||||
? { to: args.to, threadImplicit: true }
|
||||
: null,
|
||||
extractToolSendResult: ({ result }: { result: unknown }) => {
|
||||
const toolSend = (result as { details?: { toolSend?: Record<string, unknown> } })?.details
|
||||
?.toolSend;
|
||||
const to = typeof toolSend?.to === "string" ? toolSend.to : undefined;
|
||||
if (!to) {
|
||||
return null;
|
||||
}
|
||||
const threadId = typeof toolSend?.threadId === "string" ? toolSend.threadId : undefined;
|
||||
return {
|
||||
to,
|
||||
...(threadId ? { threadId } : {}),
|
||||
...(toolSend?.threadImplicit === true ? { threadImplicit: true } : {}),
|
||||
...(toolSend?.threadSuppressed === true ? { threadSuppressed: true } : {}),
|
||||
};
|
||||
},
|
||||
},
|
||||
threading: {
|
||||
resolveAutoThreadId: ({ toolContext }: { toolContext?: { currentThreadTs?: string } }) =>
|
||||
toolContext?.currentThreadTs,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function registerPartialResultProvider(): void {
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{ pluginId: PARTIAL_RESULT_PROVIDER, source: "test", plugin: createPartialResultPlugin() },
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
describe("extractMessagingToolSendResult thread evidence", () => {
|
||||
afterEach(() => {
|
||||
setActivePluginRegistry(createTestRegistry());
|
||||
});
|
||||
|
||||
it("preserves implicit thread evidence when the provider result omits it", () => {
|
||||
registerPartialResultProvider();
|
||||
|
||||
const pending = extractMessagingToolSend(
|
||||
"message",
|
||||
{ action: "send", provider: PARTIAL_RESULT_PROVIDER, to: "channel:abc", message: "answer" },
|
||||
{
|
||||
currentChannelId: "channel:abc",
|
||||
currentMessagingTarget: "channel:abc",
|
||||
currentThreadId: "root-1",
|
||||
replyToMode: "all",
|
||||
},
|
||||
);
|
||||
expect(pending?.threadImplicit).toBe(true);
|
||||
expect(pending?.threadId).toBe("root-1");
|
||||
|
||||
const confirmed = extractMessagingToolSendResult(pending!, {
|
||||
details: { toolSend: { to: "channel:abc" } },
|
||||
});
|
||||
expect(confirmed.threadImplicit).toBe(true);
|
||||
expect(confirmed.threadId).toBe("root-1");
|
||||
|
||||
const matches = getMatchingMessagingToolReplyTargets({
|
||||
messageProvider: PARTIAL_RESULT_PROVIDER,
|
||||
originatingTo: "channel:abc",
|
||||
originatingThreadId: "root-1",
|
||||
messagingToolSentTargets: [confirmed],
|
||||
});
|
||||
expect(matches).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("lets an explicit provider-reported thread override pending implicit evidence", () => {
|
||||
registerPartialResultProvider();
|
||||
|
||||
const confirmed = extractMessagingToolSendResult(
|
||||
{
|
||||
tool: "message",
|
||||
provider: PARTIAL_RESULT_PROVIDER,
|
||||
to: "channel:abc",
|
||||
threadImplicit: true,
|
||||
},
|
||||
{ details: { toolSend: { to: "channel:abc", threadId: "root-9" } } },
|
||||
);
|
||||
expect(confirmed.threadId).toBe("root-9");
|
||||
expect(confirmed.threadImplicit).toBeUndefined();
|
||||
});
|
||||
|
||||
it.each([
|
||||
{
|
||||
name: "provider suppression replaces pending implicit evidence",
|
||||
pending: {
|
||||
threadId: "root-1",
|
||||
threadImplicit: true,
|
||||
},
|
||||
result: {
|
||||
threadSuppressed: true,
|
||||
},
|
||||
expected: {
|
||||
threadId: undefined,
|
||||
threadImplicit: undefined,
|
||||
threadSuppressed: true,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "provider implicit evidence replaces pending suppression",
|
||||
pending: {
|
||||
threadSuppressed: true,
|
||||
},
|
||||
result: {
|
||||
threadImplicit: true,
|
||||
},
|
||||
expected: {
|
||||
threadId: undefined,
|
||||
threadImplicit: true,
|
||||
threadSuppressed: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "a partial result preserves pending suppression",
|
||||
pending: {
|
||||
threadSuppressed: true,
|
||||
},
|
||||
result: {},
|
||||
expected: {
|
||||
threadId: undefined,
|
||||
threadImplicit: undefined,
|
||||
threadSuppressed: true,
|
||||
},
|
||||
},
|
||||
])("$name", ({ pending, result, expected }) => {
|
||||
registerPartialResultProvider();
|
||||
|
||||
const confirmed = extractMessagingToolSendResult(
|
||||
{
|
||||
tool: "message",
|
||||
provider: PARTIAL_RESULT_PROVIDER,
|
||||
to: "channel:abc",
|
||||
...pending,
|
||||
},
|
||||
{ details: { toolSend: { to: "channel:abc", ...result } } },
|
||||
);
|
||||
|
||||
expect({
|
||||
threadId: confirmed.threadId,
|
||||
threadImplicit: confirmed.threadImplicit,
|
||||
threadSuppressed: confirmed.threadSuppressed,
|
||||
}).toEqual(expected);
|
||||
});
|
||||
});
|
||||
@@ -972,21 +972,13 @@ export function extractMessagingToolSendResult(
|
||||
if (!extracted?.to) {
|
||||
return pending;
|
||||
}
|
||||
const extractedThreadId = normalizeOptionalString(extracted.threadId);
|
||||
const providerReportedThread =
|
||||
extractedThreadId != null ||
|
||||
extracted.threadImplicit === true ||
|
||||
extracted.threadSuppressed === true;
|
||||
// Thread route fields are one state. Mixing provider and pending values can
|
||||
// create contradictory implicit and suppressed evidence.
|
||||
const threadEvidence = providerReportedThread ? extracted : pending;
|
||||
return {
|
||||
...pending,
|
||||
...extracted,
|
||||
accountId: normalizeOptionalString(extracted.accountId) ?? pending.accountId,
|
||||
to: normalizeTargetForProvider(providerId ?? pending.provider, extracted.to),
|
||||
threadId: normalizeOptionalString(threadEvidence.threadId),
|
||||
threadImplicit: threadEvidence.threadImplicit === true ? true : undefined,
|
||||
threadSuppressed: threadEvidence.threadSuppressed === true ? true : undefined,
|
||||
threadId: normalizeOptionalString(extracted.threadId),
|
||||
threadImplicit: extracted.threadImplicit === true ? true : undefined,
|
||||
threadSuppressed: extracted.threadSuppressed === true ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -17,7 +17,10 @@ export type AgentGeneratedAttachment = {
|
||||
name?: string;
|
||||
};
|
||||
|
||||
function generatedAttachmentReference(attachment: AgentGeneratedAttachment): string | undefined {
|
||||
/** Resolve the first usable path or URL reference for a generated attachment. */
|
||||
export function generatedAttachmentReference(
|
||||
attachment: AgentGeneratedAttachment,
|
||||
): string | undefined {
|
||||
return normalizeOptionalString(
|
||||
attachment.path ?? attachment.url ?? attachment.mediaUrl ?? attachment.filePath,
|
||||
);
|
||||
@@ -32,7 +35,10 @@ export function mediaUrlsFromGeneratedAttachments(
|
||||
);
|
||||
}
|
||||
|
||||
function nameFromGeneratedAttachment(attachment: AgentGeneratedAttachment): string | undefined {
|
||||
/** Resolve a display name from attachment metadata or path basename. */
|
||||
export function nameFromGeneratedAttachment(
|
||||
attachment: AgentGeneratedAttachment,
|
||||
): string | undefined {
|
||||
return (
|
||||
normalizeOptionalString(attachment.name) ??
|
||||
basenameFromAnyPath(generatedAttachmentReference(attachment) ?? "")
|
||||
|
||||
@@ -173,6 +173,11 @@ export function collectAnthropicApiKeys(): string[] {
|
||||
return collectProviderApiKeys("anthropic");
|
||||
}
|
||||
|
||||
/** Collect Gemini API keys for live cache/model tests. */
|
||||
export function collectGeminiApiKeys(): string[] {
|
||||
return collectProviderApiKeys("google");
|
||||
}
|
||||
|
||||
/** Return whether a provider error message indicates API-key rate limiting. */
|
||||
export function isApiKeyRateLimitError(message: string): boolean {
|
||||
const lower = normalizeLowercaseStringOrEmpty(message);
|
||||
@@ -197,6 +202,11 @@ export function isApiKeyRateLimitError(message: string): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Return whether an Anthropic error message indicates rate limiting. */
|
||||
export function isAnthropicRateLimitError(message: string): boolean {
|
||||
return isApiKeyRateLimitError(message);
|
||||
}
|
||||
|
||||
/** Return whether an Anthropic error message indicates billing exhaustion. */
|
||||
export function isAnthropicBillingError(message: string): boolean {
|
||||
const lower = normalizeLowercaseStringOrEmpty(message);
|
||||
|
||||
@@ -16,7 +16,7 @@ import { loadUndiciRuntimeDeps } from "../infra/net/undici-runtime.js";
|
||||
export type { FetchLike };
|
||||
|
||||
/** Default MCP HTTP fetch backed by lazy-loaded undici runtime deps. */
|
||||
const fetchWithUndici: FetchLike = async (url, init) =>
|
||||
export const fetchWithUndici: FetchLike = async (url, init) =>
|
||||
(await loadUndiciRuntimeDeps().fetch(
|
||||
url,
|
||||
init as Parameters<ReturnType<typeof loadUndiciRuntimeDeps>["fetch"]>[1],
|
||||
|
||||
@@ -47,6 +47,15 @@ export function listProviderEnvAuthLookupKeys(params: {
|
||||
).toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
/** Resolves provider auth lookup maps and returns their sorted provider keys. */
|
||||
export function resolveProviderEnvAuthLookupKeys(params?: ProviderEnvVarLookupParams): string[] {
|
||||
const lookupMaps = resolveProviderEnvAuthLookupMaps(params);
|
||||
return listProviderEnvAuthLookupKeys({
|
||||
envCandidateMap: lookupMaps.envCandidateMap,
|
||||
authEvidenceMap: lookupMaps.authEvidenceMap,
|
||||
});
|
||||
}
|
||||
|
||||
/** Lists known provider API-key env var names for redaction and marker matching. */
|
||||
export function listKnownProviderEnvApiKeyNames(): string[] {
|
||||
return listKnownProviderAuthEnvVarNames();
|
||||
|
||||
@@ -784,120 +784,6 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
|
||||
expect(ids).toEqual(expectedIds);
|
||||
});
|
||||
|
||||
it("keeps finalized OpenAI Responses calls and drops partialJson streaming artifacts", () => {
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "toolUse",
|
||||
content: [
|
||||
// complete tool call — kept as-is
|
||||
{ type: "toolCall", id: "call_ok", name: "read", arguments: { path: "/a" } },
|
||||
// Legacy generic Responses transport persisted finalized toolUse
|
||||
// turns with partialJson; repair strips the scratch field.
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_partial|fc_123",
|
||||
name: "Bash",
|
||||
arguments: { command: "ls" },
|
||||
partialJson: '{"command": "ls"}',
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_empty|fc_789",
|
||||
name: "session_status",
|
||||
arguments: {},
|
||||
partialJson: "",
|
||||
},
|
||||
// Anthropic can persist initialized tool calls with arguments: {}
|
||||
// plus partialJson if the stream aborts before content_block_stop.
|
||||
// Those incomplete artifacts must be dropped.
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "toolu_123",
|
||||
name: "Bash",
|
||||
arguments: {},
|
||||
partialJson: '{"command":',
|
||||
},
|
||||
// An OpenAI-shaped id and parsed partial arguments do not prove that
|
||||
// response.output_item.done arrived.
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_truncated|fc_456",
|
||||
name: "Bash",
|
||||
arguments: { command: "ls" },
|
||||
partialJson: '{"command":"ls"',
|
||||
},
|
||||
// Missing required input is also an interrupted artifact and should drop.
|
||||
{
|
||||
type: "toolUse",
|
||||
id: "call_partial2",
|
||||
name: "read",
|
||||
input: null,
|
||||
partialJson: '{"path":',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "retry" },
|
||||
]);
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
const ids = toolCalls.map((t) => (t as { id?: unknown }).id);
|
||||
expect(ids).toEqual(["call_ok", "call_partial|fc_123", "call_empty|fc_789"]);
|
||||
expect(toolCalls[1]).not.toHaveProperty("partialJson");
|
||||
expect(toolCalls[2]).not.toHaveProperty("partialJson");
|
||||
});
|
||||
|
||||
it("strips finalized partialJson without rewriting sessions_spawn arguments", () => {
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "toolUse",
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_spawn|fc_456",
|
||||
name: "sessions_spawn",
|
||||
arguments: { attachments: [{ content: "secret data" }] },
|
||||
partialJson: '{"attachments":[{"content":"secret data"}]}',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
const toolCalls = getAssistantToolCallBlocks(out);
|
||||
expect(toolCalls).toHaveLength(1);
|
||||
expect(toolCalls[0]).not.toHaveProperty("partialJson");
|
||||
expect((toolCalls[0] as { arguments?: unknown }).arguments).toEqual({
|
||||
attachments: [{ content: "secret data" }],
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["stop", "aborted", "error", "length"] as const)(
|
||||
"drops OpenAI Responses partialJson blocks on %s assistant turns",
|
||||
(stopReason) => {
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason,
|
||||
content: [
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_partial|fc_123",
|
||||
name: "Bash",
|
||||
arguments: { command: "ls" },
|
||||
partialJson: '{"command":"ls"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
{ role: "user", content: "retry" },
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallInputs(input);
|
||||
expect(getAssistantToolCallBlocks(out)).toHaveLength(0);
|
||||
},
|
||||
);
|
||||
|
||||
it("keeps valid tool calls and preserves text blocks", () => {
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
@@ -949,36 +835,6 @@ describe("sanitizeToolCallInputs allowed-name filtering", () => {
|
||||
expect(out).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("drops signed-thinking assistant turns with partialJson tool calls", () => {
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
role: "assistant",
|
||||
stopReason: "toolUse",
|
||||
content: [
|
||||
{
|
||||
type: "thinking",
|
||||
thinking: "Let me run a command.",
|
||||
thinkingSignature: "sig_partial",
|
||||
},
|
||||
{
|
||||
type: "toolCall",
|
||||
id: "call_partial|fc_123",
|
||||
name: "exec",
|
||||
arguments: {},
|
||||
partialJson: '{"command":"ls"}',
|
||||
},
|
||||
],
|
||||
},
|
||||
]);
|
||||
|
||||
const out = sanitizeToolCallInputs(input, {
|
||||
allowedToolNames: ["exec"],
|
||||
allowProviderOwnedThinkingReplay: true,
|
||||
});
|
||||
|
||||
expect(out).toStrictEqual([]);
|
||||
});
|
||||
|
||||
it("drops signed-thinking assistant turns when sibling tool calls reuse an id", () => {
|
||||
const input = castAgentMessages([
|
||||
{
|
||||
|
||||
@@ -5,7 +5,6 @@
|
||||
*/
|
||||
import {
|
||||
hasNonEmptyString as hasNonEmptyStringField,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
@@ -28,7 +27,6 @@ type RawToolCallBlock = {
|
||||
name?: unknown;
|
||||
input?: unknown;
|
||||
arguments?: unknown;
|
||||
partialJson?: unknown;
|
||||
};
|
||||
|
||||
const RAW_TOOL_CALL_BLOCK_TYPES = new Set([
|
||||
@@ -74,45 +72,6 @@ function hasToolCallId(block: RawToolCallBlock): boolean {
|
||||
);
|
||||
}
|
||||
|
||||
function hasPartialJson(
|
||||
block: RawToolCallBlock,
|
||||
): block is RawToolCallBlock & { partialJson: string } {
|
||||
return typeof block.partialJson === "string";
|
||||
}
|
||||
|
||||
function isCompleteJsonObject(value: string): boolean {
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(value);
|
||||
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function isFinalizedOpenAIResponsesToolCall(
|
||||
message: AgentMessage,
|
||||
block: RawToolCallBlock,
|
||||
): boolean {
|
||||
if (
|
||||
message.role !== "assistant" ||
|
||||
!("stopReason" in message) ||
|
||||
message.stopReason !== "toolUse" ||
|
||||
!hasPartialJson(block) ||
|
||||
typeof block.id !== "string" ||
|
||||
"input" in block ||
|
||||
!block.arguments ||
|
||||
typeof block.arguments !== "object" ||
|
||||
Array.isArray(block.arguments) ||
|
||||
(!isCompleteJsonObject(block.partialJson) &&
|
||||
(block.partialJson.trim() !== "" || Object.keys(block.arguments).length > 0))
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const separator = block.id.indexOf("|");
|
||||
return separator > 0 && separator < block.id.length - 1;
|
||||
}
|
||||
|
||||
function sanitizeToolCallBlock(block: RawToolCallBlock): RawToolCallBlock {
|
||||
// This repair path normalizes replay shape only. Tool payloads are local
|
||||
// trusted-operator transcript state per SECURITY.md, so do not redact or
|
||||
@@ -157,7 +116,6 @@ function isReplaySafeThinkingAssistantTurn(
|
||||
const toolCallId = typeof block.id === "string" ? block.id.trim() : "";
|
||||
if (
|
||||
!hasToolCallInput(block) ||
|
||||
hasPartialJson(block) ||
|
||||
!toolCallId ||
|
||||
seenToolCallIds.has(toolCallId) ||
|
||||
!isAllowedToolCallName(block.name, allowedToolNames)
|
||||
@@ -424,72 +382,31 @@ function repairToolCallInputs(
|
||||
let messageChanged = false;
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (isRawToolCallBlock(block)) {
|
||||
// Drop genuinely incomplete streaming artifacts (missing required fields).
|
||||
if (
|
||||
!hasToolCallInput(block) ||
|
||||
if (
|
||||
isRawToolCallBlock(block) &&
|
||||
(!hasToolCallInput(block) ||
|
||||
!hasToolCallId(block) ||
|
||||
!isAllowedToolCallName((block as RawToolCallBlock).name, allowedToolNames)
|
||||
) {
|
||||
droppedToolCalls += 1;
|
||||
droppedInMessage += 1;
|
||||
changed = true;
|
||||
messageChanged = true;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
let workBlock = block;
|
||||
if (isRawToolCallBlock(block) && hasPartialJson(block)) {
|
||||
if (!isFinalizedOpenAIResponsesToolCall(msg, block)) {
|
||||
droppedToolCalls += 1;
|
||||
droppedInMessage += 1;
|
||||
changed = true;
|
||||
messageChanged = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Legacy generic Responses transport persisted successful toolUse turns
|
||||
// with the scratch buffer intact. Strip it only when terminal state and
|
||||
// the provider-specific finalized shape both prove completion.
|
||||
const stripped = { ...block };
|
||||
delete (stripped as RawToolCallBlock & { partialJson?: unknown }).partialJson;
|
||||
workBlock = stripped;
|
||||
!isAllowedToolCallName((block as RawToolCallBlock).name, allowedToolNames))
|
||||
) {
|
||||
droppedToolCalls += 1;
|
||||
droppedInMessage += 1;
|
||||
changed = true;
|
||||
messageChanged = true;
|
||||
continue;
|
||||
}
|
||||
if (isRawToolCallBlock(workBlock)) {
|
||||
if (RAW_TOOL_CALL_BLOCK_TYPES.has((workBlock as { type?: string }).type ?? "")) {
|
||||
// Only sanitize (redact) sessions_spawn blocks; all others are passed through
|
||||
// unchanged to preserve provider-specific shapes (e.g. toolUse.input for Anthropic).
|
||||
const blockName =
|
||||
typeof (workBlock as { name?: unknown }).name === "string"
|
||||
? (workBlock as { name: string }).name.trim()
|
||||
: undefined;
|
||||
if (normalizeLowercaseStringOrEmpty(blockName) === "sessions_spawn") {
|
||||
const sanitized = sanitizeToolCallBlock(workBlock);
|
||||
if (sanitized !== workBlock) {
|
||||
changed = true;
|
||||
messageChanged = true;
|
||||
}
|
||||
nextContent.push(sanitized as typeof block);
|
||||
} else if (typeof (workBlock as { name?: unknown }).name === "string") {
|
||||
const rawName = (workBlock as { name: string }).name;
|
||||
const trimmedName = rawName.trim();
|
||||
if (rawName !== trimmedName && trimmedName) {
|
||||
const renamed = { ...(workBlock as object), name: trimmedName } as typeof block;
|
||||
nextContent.push(renamed);
|
||||
changed = true;
|
||||
messageChanged = true;
|
||||
} else {
|
||||
nextContent.push(workBlock);
|
||||
}
|
||||
} else {
|
||||
nextContent.push(workBlock);
|
||||
if (isRawToolCallBlock(block)) {
|
||||
if (RAW_TOOL_CALL_BLOCK_TYPES.has((block as { type?: string }).type ?? "")) {
|
||||
const sanitized = sanitizeToolCallBlock(block);
|
||||
if (sanitized !== block) {
|
||||
changed = true;
|
||||
messageChanged = true;
|
||||
}
|
||||
nextContent.push(sanitized as typeof block);
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
nextContent.push(block);
|
||||
}
|
||||
nextContent.push(workBlock);
|
||||
}
|
||||
|
||||
if (droppedInMessage > 0) {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user