mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-17 11:38:44 +08:00
Compare commits
42 Commits
codex-netw
...
ak/cron-mi
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
37215d16eb | ||
|
|
4e5b8f624c | ||
|
|
47ec08016b | ||
|
|
45843ac055 | ||
|
|
b500a488e4 | ||
|
|
645fe838ff | ||
|
|
4fee348764 | ||
|
|
0471275270 | ||
|
|
203bddcdb7 | ||
|
|
c6d549c5a7 | ||
|
|
176572cb35 | ||
|
|
55c047e77e | ||
|
|
58a8142a33 | ||
|
|
2e7caba557 | ||
|
|
0fd0e7cb92 | ||
|
|
a89e6e05ef | ||
|
|
08ff253e5f | ||
|
|
033bb86133 | ||
|
|
790e00a303 | ||
|
|
2cfcb3c932 | ||
|
|
9ed9d389e0 | ||
|
|
d697ecf172 | ||
|
|
6d22b8eb24 | ||
|
|
c14793d35a | ||
|
|
f90ec6d7be | ||
|
|
1a002c2d9d | ||
|
|
a55f625b09 | ||
|
|
21d3a70826 | ||
|
|
9b49387ad8 | ||
|
|
7e9b9421bd | ||
|
|
ff5e73539a | ||
|
|
b5648b1d5e | ||
|
|
0ab4cd7c52 | ||
|
|
042ebb4f75 | ||
|
|
1ae0eacf4b | ||
|
|
c06b7959ec | ||
|
|
aeb5b794c9 | ||
|
|
e83926747c | ||
|
|
e51c0c8cea | ||
|
|
67c55ccce8 | ||
|
|
385d1ada91 | ||
|
|
7fc124dcf1 |
@@ -16,6 +16,15 @@ 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
|
||||
@@ -36,6 +45,8 @@ 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
|
||||
|
||||
@@ -114,6 +125,10 @@ 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.value);
|
||||
const headers = config.headers(secret);
|
||||
const response = await fetch(config.url, {
|
||||
headers,
|
||||
signal: controller.signal,
|
||||
@@ -69,25 +69,32 @@ const providers = {
|
||||
openai: {
|
||||
env: ["OPENAI_API_KEY"],
|
||||
url: "https://api.openai.com/v1/models",
|
||||
headers: (token) => ({ authorization: `Bearer ${token}` }),
|
||||
headers: ({ value }) => ({ authorization: `Bearer ${value}` }),
|
||||
},
|
||||
anthropic: {
|
||||
env: ["ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
|
||||
env: ["ANTHROPIC_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_API_TOKEN"],
|
||||
url: "https://api.anthropic.com/v1/models",
|
||||
headers: (token) => ({
|
||||
"anthropic-version": "2023-06-01",
|
||||
"x-api-key": token,
|
||||
}),
|
||||
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,
|
||||
},
|
||||
},
|
||||
fireworks: {
|
||||
env: ["FIREWORKS_API_KEY"],
|
||||
url: "https://api.fireworks.ai/inference/v1/models",
|
||||
headers: (token) => ({ authorization: `Bearer ${token}` }),
|
||||
headers: ({ value }) => ({ authorization: `Bearer ${value}` }),
|
||||
},
|
||||
openrouter: {
|
||||
env: ["OPENROUTER_API_KEY"],
|
||||
url: "https://openrouter.ai/api/v1/models",
|
||||
headers: (token) => ({ authorization: `Bearer ${token}` }),
|
||||
headers: ({ value }) => ({ authorization: `Bearer ${value}` }),
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git \
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
@@ -210,6 +210,7 @@ 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git \
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
@@ -128,6 +128,7 @@ 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git \
|
||||
timeout --signal=TERM --kill-after=10s 120s git \
|
||||
-c protocol.version=2 \
|
||||
fetch --no-tags --prune --no-recurse-submodules --depth=50 origin \
|
||||
"+refs/heads/main:refs/remotes/origin/main"
|
||||
@@ -113,6 +113,7 @@ 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 30s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$GITHUB_WORKSPACE" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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 30s git -C "$workdir" \
|
||||
timeout --signal=TERM --kill-after=10s 120s 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,6 +663,7 @@ 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 }}
|
||||
|
||||
@@ -229,6 +229,8 @@ on:
|
||||
required: false
|
||||
ANTHROPIC_API_TOKEN:
|
||||
required: false
|
||||
ANTHROPIC_OAUTH_TOKEN:
|
||||
required: false
|
||||
FACTORY_API_KEY:
|
||||
required: false
|
||||
BYTEPLUS_API_KEY:
|
||||
@@ -519,6 +521,7 @@ 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:
|
||||
@@ -541,10 +544,13 @@ jobs:
|
||||
echo "Missing OPENAI_API_KEY secret for live-cache validation." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ -z "${ANTHROPIC_API_KEY:-}" ]]; then
|
||||
echo "Missing ANTHROPIC_API_KEY secret for live-cache validation." >&2
|
||||
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
|
||||
exit 1
|
||||
fi
|
||||
if [[ -n "${ANTHROPIC_OAUTH_TOKEN:-}" ]]; then
|
||||
echo "ANTHROPIC_API_KEY=" >> "$GITHUB_ENV"
|
||||
fi
|
||||
|
||||
- name: Verify live prompt cache floors
|
||||
run: |
|
||||
@@ -680,6 +686,7 @@ 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 }}
|
||||
@@ -944,6 +951,7 @@ 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 }}
|
||||
@@ -1655,6 +1663,7 @@ 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 }}
|
||||
@@ -1746,7 +1755,7 @@ jobs:
|
||||
}
|
||||
|
||||
case "${LIVE_MODEL_PROVIDERS}" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
anthropic) require_any Anthropic ANTHROPIC_OAUTH_TOKEN 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 ;;
|
||||
@@ -1778,6 +1787,7 @@ 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 }}
|
||||
@@ -1921,7 +1931,7 @@ jobs:
|
||||
IFS=',' read -r -a providers <<<"${OPENCLAW_LIVE_PROVIDERS}"
|
||||
for provider in "${providers[@]}"; do
|
||||
case "$provider" in
|
||||
anthropic) require_any Anthropic ANTHROPIC_API_KEY ANTHROPIC_API_KEY_OLD ANTHROPIC_API_TOKEN ;;
|
||||
anthropic) require_any Anthropic ANTHROPIC_OAUTH_TOKEN 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 ;;
|
||||
@@ -2140,6 +2150,7 @@ 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 }}
|
||||
@@ -2222,7 +2233,11 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$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_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
@@ -2356,6 +2371,7 @@ 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 }}
|
||||
@@ -2447,7 +2463,11 @@ jobs:
|
||||
case "${{ matrix.suite_id }}" in
|
||||
live-cli-backend-docker)
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_MODEL=claude-cli/claude-sonnet-4-6" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_LIVE_CLI_BACKEND_AUTH=api-key" >> "$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_DEBUG=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
|
||||
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
|
||||
@@ -2568,6 +2588,7 @@ 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,6 +631,7 @@ 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 }}
|
||||
@@ -724,6 +725,7 @@ 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,6 +38,7 @@ 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,6 +203,8 @@ on:
|
||||
required: false
|
||||
ANTHROPIC_API_TOKEN:
|
||||
required: false
|
||||
ANTHROPIC_OAUTH_TOKEN:
|
||||
required: false
|
||||
FACTORY_API_KEY:
|
||||
required: false
|
||||
BYTEPLUS_API_KEY:
|
||||
@@ -588,6 +590,7 @@ 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,6 +34,7 @@ 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)
|
||||
|
||||
@@ -491,6 +491,7 @@ Model override note:
|
||||
enabled: true,
|
||||
store: "~/.openclaw/cron/jobs.json",
|
||||
maxConcurrentRuns: 8,
|
||||
minInterval: "5m",
|
||||
retry: {
|
||||
maxAttempts: 3,
|
||||
backoffMs: [60000, 120000, 300000],
|
||||
@@ -505,6 +506,8 @@ Model override note:
|
||||
|
||||
`maxConcurrentRuns` limits both scheduled cron dispatch and isolated agent-turn execution, and defaults to 8. Isolated cron agent turns use the queue's dedicated `cron-nested` execution lane internally, so raising this value lets independent cron LLM runs progress in parallel instead of only starting their outer cron wrappers. The shared non-cron `nested` lane is not widened by this setting.
|
||||
|
||||
`minInterval` is an optional guardrail against accidental or wasteful high-frequency schedules. It sets the minimum allowed gap between fires for recurring `every` and `cron` jobs, accepting a duration string (`30s`, `5m`, `1h`) or a number of milliseconds (bare numbers are milliseconds). Enforcement is layered. Creating or editing a recurring job whose schedule would fire more often than the floor is rejected with a clear error, so the agent or CLI caller learns to back off instead of scheduling a runaway job; for `cron` expressions this creation check samples upcoming fires (an expression like `0,1 * * * *`, which fires one minute apart, is caught) and is best-effort feedback. The scheduler then enforces the limit at fire time: after each run, the next fire is paced so consecutive fires stay at least `minInterval` apart. This covers jobs created before the limit was configured and cron expressions whose tight gaps sampling cannot prove, and each deferred fire logs a warning naming the job. Transient-failure retries follow `cron.retry` and are not paced. One-shot `at` jobs are exempt, and the default (`0` / unset) imposes no minimum.
|
||||
|
||||
`cron.store` is a logical store key and legacy doctor import path. Run `openclaw doctor --fix` to import existing JSON stores into SQLite and archive them; future cron changes should go through the CLI or Gateway API.
|
||||
|
||||
Disable cron: `cron.enabled: false` or `OPENCLAW_SKIP_CRON=1`.
|
||||
|
||||
@@ -221,6 +221,10 @@ Cron does not classify final-output prose or approval-looking refusal phrases as
|
||||
|
||||
`cron list` and run history surface the denial reason instead of reporting a blocked command as `ok`.
|
||||
|
||||
## Minimum interval
|
||||
|
||||
`cron.minInterval` (default unset / no limit) is an optional guardrail that bounds how often recurring jobs may fire. Set it to a duration (`30s`, `5m`, `1h`) or a number of milliseconds: `cron add`/`cron update` reject recurring `every` or `cron` schedules below the floor, and the scheduler also paces fires at run time so consecutive fires stay at least the floor apart — including for jobs created before the limit was set. One-shot `at` jobs are exempt. This protects against accidental or wasteful high-frequency schedules from agents or scripts.
|
||||
|
||||
## Retention
|
||||
|
||||
Retention and pruning are controlled in config:
|
||||
|
||||
@@ -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`) 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`) plus a summary grouped by session label 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.
|
||||
|
||||
@@ -1295,6 +1295,7 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
|
||||
cron: {
|
||||
enabled: true,
|
||||
maxConcurrentRuns: 8, // default; cron dispatch + isolated cron agent-turn execution
|
||||
minInterval: "5m", // optional floor for recurring every/cron jobs; omit/0 = no limit
|
||||
webhook: "https://example.invalid/legacy", // deprecated fallback for stored notify:true jobs
|
||||
webhookToken: "replace-with-dedicated-token", // optional bearer token for outbound webhook auth
|
||||
sessionRetention: "24h", // duration string or false
|
||||
@@ -1306,6 +1307,7 @@ Current builds no longer include the TCP bridge. Nodes connect over the Gateway
|
||||
}
|
||||
```
|
||||
|
||||
- `minInterval`: minimum allowed interval between fires for recurring `every` and `cron` jobs, as a duration string (`30s`, `5m`, `1h`) or a number of milliseconds. Creating or editing a recurring job below this floor is rejected, and the scheduler paces fires at run time so consecutive fires stay at least the floor apart (covers jobs created before the limit was set; deferred fires log a warning). One-shot `at` jobs are exempt. Default: unset (`0`, no limit).
|
||||
- `sessionRetention`: how long to keep completed isolated cron run sessions before pruning from `sessions.json`. Also controls cleanup of archived deleted cron transcripts. Default: `24h`; set `false` to disable.
|
||||
- `runLog.maxBytes`: accepted for compatibility with older file-backed cron run logs. Default: `2_000_000` bytes.
|
||||
- `runLog.keepLines`: newest SQLite run-history rows retained per job. Default: `2000`.
|
||||
|
||||
@@ -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 `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 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 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,45 +103,8 @@ 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,45 +561,8 @@ 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,3 +108,47 @@ 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,12 +69,18 @@ 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 {
|
||||
if (MODELS[modelId]) {
|
||||
return MODELS[modelId];
|
||||
const bare = stripInferenceProfilePrefix(modelId);
|
||||
if (MODELS[bare]) {
|
||||
return MODELS[bare];
|
||||
}
|
||||
const parts = modelId.split(":");
|
||||
const parts = bare.split(":");
|
||||
for (let i = parts.length - 1; i >= 1; i--) {
|
||||
const spec = MODELS[parts.slice(0, i).join(":")];
|
||||
if (spec) {
|
||||
@@ -86,7 +92,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(modelId);
|
||||
const id = normalizeLowercaseStringOrEmpty(stripInferenceProfilePrefix(modelId));
|
||||
if (id.startsWith("amazon.titan-embed-text-v2")) {
|
||||
return "titan-v2";
|
||||
}
|
||||
@@ -312,6 +318,7 @@ function parseCohereBatch(family: Family, raw: string): number[][] {
|
||||
export const testing = {
|
||||
parseCohereBatch,
|
||||
parseSingle,
|
||||
stripInferenceProfilePrefix,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -193,47 +193,6 @@
|
||||
"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"
|
||||
},
|
||||
@@ -426,81 +385,6 @@
|
||||
"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,17 +218,12 @@ function resolveBoundedThreadConfig(
|
||||
params: CodexBoundedTurnParams,
|
||||
workspace: { codexHome?: string },
|
||||
): JsonObject {
|
||||
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,
|
||||
};
|
||||
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;
|
||||
}
|
||||
|
||||
function buildPrivateCodexAppServerStartOptions(
|
||||
|
||||
@@ -125,89 +125,6 @@ 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, JsonObject, JsonValue } from "./protocol.js";
|
||||
import type { CodexSandboxPolicy, CodexServiceTier } from "./protocol.js";
|
||||
|
||||
const START_OPTIONS_KEY_SECRET_SYMBOL = Symbol.for("openclaw.codexAppServerStartOptionsKeySecret");
|
||||
const START_OPTIONS_KEY_SECRET = getStartOptionsKeySecret();
|
||||
@@ -111,32 +111,6 @@ 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;
|
||||
@@ -177,7 +151,6 @@ export type CodexAppServerRuntimeOptions = {
|
||||
sandbox: CodexAppServerSandboxMode;
|
||||
approvalsReviewer: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier;
|
||||
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
|
||||
};
|
||||
|
||||
export type CodexModelBackedReviewerContext = {
|
||||
@@ -215,7 +188,6 @@ export type CodexPluginConfig = {
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
approvalsReviewer?: CodexAppServerApprovalsReviewer;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
networkProxy?: CodexAppServerNetworkProxyConfig;
|
||||
defaultWorkspaceDir?: string;
|
||||
experimental?: CodexAppServerExperimentalConfig;
|
||||
};
|
||||
@@ -244,7 +216,6 @@ export const CODEX_APP_SERVER_CONFIG_KEYS = [
|
||||
"sandbox",
|
||||
"approvalsReviewer",
|
||||
"serviceTier",
|
||||
"networkProxy",
|
||||
"defaultWorkspaceDir",
|
||||
"experimental",
|
||||
] as const;
|
||||
@@ -278,7 +249,6 @@ 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"]);
|
||||
@@ -303,25 +273,6 @@ 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({
|
||||
@@ -383,7 +334,6 @@ const codexPluginConfigSchema = z
|
||||
sandbox: codexAppServerSandboxSchema.optional(),
|
||||
approvalsReviewer: codexAppServerApprovalsReviewerSchema.optional(),
|
||||
serviceTier: codexAppServerServiceTierSchema,
|
||||
networkProxy: codexAppServerNetworkProxySchema.optional(),
|
||||
defaultWorkspaceDir: z.string().optional(),
|
||||
experimental: codexAppServerExperimentalSchema.optional(),
|
||||
})
|
||||
@@ -599,11 +549,6 @@ 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",
|
||||
@@ -652,14 +597,17 @@ export function resolveCodexAppServerRuntimeOptions(
|
||||
: {}),
|
||||
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
|
||||
approvalPolicySource,
|
||||
sandbox: resolvedSandbox,
|
||||
sandbox:
|
||||
forcedPolicy?.sandbox ??
|
||||
configuredSandbox ??
|
||||
defaultPolicy?.sandbox ??
|
||||
(policyMode === "guardian" ? "workspace-write" : "danger-full-access"),
|
||||
approvalsReviewer:
|
||||
forcedPolicy?.approvalsReviewer ??
|
||||
explicitApprovalsReviewer ??
|
||||
defaultPolicy?.approvalsReviewer ??
|
||||
(policyMode === "guardian" ? "auto_review" : "user"),
|
||||
...(serviceTier ? { serviceTier } : {}),
|
||||
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -873,69 +821,6 @@ 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,12 +76,6 @@ 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;
|
||||
@@ -91,7 +85,6 @@ export type CodexThreadStartParams = JsonObject & {
|
||||
approvalPolicy?: string | JsonObject;
|
||||
approvalsReviewer?: string | null;
|
||||
sandbox?: string;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
dynamicTools?: CodexDynamicToolSpec[] | null;
|
||||
developerInstructions?: string;
|
||||
@@ -109,7 +102,6 @@ export type CodexThreadResumeParams = JsonObject & {
|
||||
approvalPolicy?: string | JsonObject;
|
||||
approvalsReviewer?: string | null;
|
||||
sandbox?: string;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
config?: JsonObject;
|
||||
developerInstructions?: string;
|
||||
@@ -161,7 +153,6 @@ export type CodexTurnStartParams = JsonObject & {
|
||||
approvalPolicy?: string | JsonObject;
|
||||
approvalsReviewer?: string | null;
|
||||
sandboxPolicy?: CodexSandboxPolicy;
|
||||
permissions?: CodexPermissionProfileSelection;
|
||||
serviceTier?: CodexServiceTier | null;
|
||||
effort?: string | null;
|
||||
personality?: string | null;
|
||||
|
||||
@@ -66,7 +66,6 @@ export type CodexAppServerThreadBinding = {
|
||||
approvalPolicy?: CodexAppServerApprovalPolicy;
|
||||
sandbox?: CodexAppServerSandboxMode;
|
||||
serviceTier?: CodexServiceTier;
|
||||
networkProxyProfileName?: string;
|
||||
dynamicToolsFingerprint?: string;
|
||||
dynamicToolsContainDeferred?: boolean;
|
||||
webSearchThreadConfigFingerprint?: string;
|
||||
@@ -182,10 +181,6 @@ 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
|
||||
@@ -261,7 +256,6 @@ 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,
|
||||
|
||||
162
extensions/codex/src/app-server/session-history.test.ts
Normal file
162
extensions/codex/src/app-server/session-history.test.ts
Normal file
@@ -0,0 +1,162 @@
|
||||
// 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,36 +86,6 @@ 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,
|
||||
@@ -429,53 +399,6 @@ 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" }),
|
||||
@@ -694,35 +617,6 @@ 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,7 +39,6 @@ import {
|
||||
import {
|
||||
isJsonObject,
|
||||
type CodexDynamicToolSpec,
|
||||
type CodexPermissionProfileSelection,
|
||||
type CodexSandboxPolicy,
|
||||
type CodexThreadResumeParams,
|
||||
type CodexThreadStartParams,
|
||||
@@ -647,7 +646,6 @@ export async function startOrResumeThread(params: {
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration:
|
||||
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
@@ -696,7 +694,6 @@ export async function startOrResumeThread(params: {
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration:
|
||||
finalConfigPatch.nativeHookRelayGeneration ?? binding.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: binding.pluginAppsFingerprint,
|
||||
@@ -797,7 +794,6 @@ export async function startOrResumeThread(params: {
|
||||
webSearchThreadConfigFingerprint,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
@@ -846,7 +842,6 @@ export async function startOrResumeThread(params: {
|
||||
dynamicToolsContainDeferred,
|
||||
userMcpServersFingerprint,
|
||||
mcpServersFingerprint: nextMcpServersFingerprint,
|
||||
networkProxyProfileName: params.appServer.networkProxy?.profileName,
|
||||
nativeHookRelayGeneration: finalConfigPatch.nativeHookRelayGeneration,
|
||||
pluginAppsFingerprint: pluginThreadConfig?.fingerprint,
|
||||
pluginAppsInputFingerprint: pluginThreadConfig?.inputFingerprint,
|
||||
@@ -1056,7 +1051,7 @@ export function buildThreadStartParams(
|
||||
cwd: options.cwd,
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
...codexThreadSandboxOrPermissions(options.appServer),
|
||||
sandbox: options.appServer.sandbox,
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
serviceName: "OpenClaw",
|
||||
@@ -1065,7 +1060,6 @@ export function buildThreadStartParams(
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
appServer: options.appServer,
|
||||
}),
|
||||
...resolveCodexThreadEnvironmentSelection(options),
|
||||
developerInstructions:
|
||||
@@ -1115,7 +1109,7 @@ export function buildThreadResumeParams(
|
||||
...(modelSelection.modelProvider ? { modelProvider: modelSelection.modelProvider } : {}),
|
||||
approvalPolicy: options.appServer.approvalPolicy,
|
||||
approvalsReviewer: options.appServer.approvalsReviewer,
|
||||
...codexThreadSandboxOrPermissions(options.appServer),
|
||||
sandbox: options.appServer.sandbox,
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
config: buildCodexRuntimeThreadConfigForRun(params, options.config, {
|
||||
@@ -1123,7 +1117,6 @@ export function buildThreadResumeParams(
|
||||
nativeProviderWebSearchSupport: options.nativeProviderWebSearchSupport,
|
||||
nativeCodeModeOnlyEnabled: options.nativeCodeModeOnlyEnabled,
|
||||
webSearchAllowed: options.webSearchAllowed,
|
||||
appServer: options.appServer,
|
||||
}),
|
||||
developerInstructions:
|
||||
options.developerInstructions ??
|
||||
@@ -1277,7 +1270,6 @@ function buildCodexRuntimeThreadConfigForRun(
|
||||
nativeProviderWebSearchSupport?: CodexNativeWebSearchSupport;
|
||||
nativeCodeModeOnlyEnabled?: boolean;
|
||||
webSearchAllowed?: boolean;
|
||||
appServer?: Pick<CodexAppServerRuntimeOptions, "networkProxy">;
|
||||
} = {},
|
||||
): JsonObject {
|
||||
const webSearchConfig = resolveCodexWebSearchPlan({
|
||||
@@ -1294,7 +1286,6 @@ function buildCodexRuntimeThreadConfigForRun(
|
||||
const runtimeConfig =
|
||||
mergeCodexThreadConfigs(
|
||||
baseConfig,
|
||||
options.appServer?.networkProxy?.configPatch,
|
||||
shouldDisableCodexToolSearchForModel(params.modelId)
|
||||
? CODEX_TOOL_SEARCH_UNSUPPORTED_THREAD_CONFIG
|
||||
: undefined,
|
||||
@@ -1335,20 +1326,14 @@ 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,
|
||||
...(useThreadPermissionProfile
|
||||
? {}
|
||||
: {
|
||||
sandboxPolicy:
|
||||
options.sandboxPolicy ??
|
||||
codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
|
||||
}),
|
||||
sandboxPolicy:
|
||||
options.sandboxPolicy ?? codexSandboxPolicyForTurn(options.appServer.sandbox, options.cwd),
|
||||
model: modelSelection.model,
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...(options.appServer.serviceTier ? { serviceTier: options.appServer.serviceTier } : {}),
|
||||
@@ -1364,20 +1349,6 @@ 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,54 +180,6 @@ 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({
|
||||
@@ -985,7 +937,7 @@ describe("codex conversation binding", () => {
|
||||
await fs.writeFile(
|
||||
`${sessionFile}.codex-app-server.json`,
|
||||
JSON.stringify({
|
||||
schemaVersion: 2,
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
approvalPolicy: "never",
|
||||
@@ -1174,7 +1126,6 @@ describe("codex conversation binding", () => {
|
||||
schemaVersion: 1,
|
||||
threadId: "thread-1",
|
||||
cwd: tempDir,
|
||||
networkProxyProfileName: "openclaw-network",
|
||||
}),
|
||||
);
|
||||
let notificationHandler: ((notification: unknown) => void) | undefined;
|
||||
@@ -1252,92 +1203,6 @@ 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,11 +30,9 @@ import {
|
||||
} from "./app-server/config.js";
|
||||
import type {
|
||||
CodexServiceTier,
|
||||
CodexPermissionProfileSelection,
|
||||
CodexThreadResumeResponse,
|
||||
CodexThreadStartResponse,
|
||||
CodexTurnStartResponse,
|
||||
JsonObject,
|
||||
JsonValue,
|
||||
} from "./app-server/protocol.js";
|
||||
import {
|
||||
@@ -417,43 +415,22 @@ 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,
|
||||
...codexConversationSandboxOrPermissions(resolved.runtime, sandbox),
|
||||
sandbox: resolved.execPolicy?.touched
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? 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,
|
||||
@@ -482,7 +459,6 @@ async function writeThreadBindingFromResponse(
|
||||
? resolved.runtime.sandbox
|
||||
: (params.sandbox ?? resolved.runtime.sandbox),
|
||||
serviceTier: params.serviceTier ?? resolved.runtime.serviceTier,
|
||||
networkProxyProfileName: resolved.runtime.networkProxy?.profileName,
|
||||
},
|
||||
{
|
||||
...resolved.agentLookup,
|
||||
@@ -592,9 +568,6 @@ 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,
|
||||
@@ -668,9 +641,7 @@ async function runBoundTurn(params: {
|
||||
cwd: workspaceDir,
|
||||
approvalPolicy,
|
||||
approvalsReviewer: modelScopedRuntime.approvalsReviewer,
|
||||
...(useStickyNetworkProfile
|
||||
? {}
|
||||
: { sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir) }),
|
||||
sandboxPolicy: codexSandboxPolicyForTurn(sandbox, workspaceDir),
|
||||
...(modelSelection?.model ? { model: modelSelection.model } : {}),
|
||||
personality: CODEX_NATIVE_PERSONALITY_NONE,
|
||||
...((binding.serviceTier ?? runtime.serviceTier)
|
||||
|
||||
@@ -436,18 +436,32 @@ export async function tryHandleDiscordMessageActionGuildAdmin(params: {
|
||||
}
|
||||
|
||||
if (action === "search") {
|
||||
const guildId = readStringParam(actionParams, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const query = readStringParam(actionParams, "query", { required: true });
|
||||
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);
|
||||
return await handleDiscordAction(
|
||||
{
|
||||
action: "searchMessages",
|
||||
accountId: accountId ?? undefined,
|
||||
guildId,
|
||||
...(guildId ? { guildId } : {}),
|
||||
content: query,
|
||||
channelId: readStringParam(actionParams, "channelId"),
|
||||
channelIds: readStringArrayParam(actionParams, "channelIds"),
|
||||
channelId,
|
||||
channelIds: explicitChannelIds,
|
||||
authorId: readStringParam(actionParams, "authorId"),
|
||||
authorIds: readStringArrayParam(actionParams, "authorIds"),
|
||||
limit: readPositiveIntegerParam(actionParams, "limit"),
|
||||
|
||||
@@ -614,4 +614,59 @@ 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,18 +184,51 @@ export async function handleDiscordMessageManagementAction(ctx: DiscordMessaging
|
||||
if (!ctx.isActionEnabled("search")) {
|
||||
throw new Error("Discord search is disabled.");
|
||||
}
|
||||
const guildId = readStringParam(ctx.params, "guildId", {
|
||||
required: true,
|
||||
});
|
||||
const content = readStringParam(ctx.params, "content", {
|
||||
required: true,
|
||||
});
|
||||
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 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 ?? []), ...(channelId ? [channelId] : [])];
|
||||
const channelIdList = [
|
||||
...(channelIds ?? []).map((id) =>
|
||||
discordMessagingActionRuntime.resolveDiscordChannelId(id),
|
||||
),
|
||||
...(channelId ? [discordMessagingActionRuntime.resolveDiscordChannelId(channelId)] : []),
|
||||
];
|
||||
if (channelIdList.length > 0) {
|
||||
for (const targetChannelId of channelIdList) {
|
||||
await ctx.assertReadTargetAllowed({ guildId, channelId: targetChannelId });
|
||||
|
||||
@@ -1139,6 +1139,72 @@ 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,6 +852,39 @@ 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,14 +415,24 @@ async function processDiscordMessageInner(
|
||||
statusReactionsActive = true;
|
||||
void statusReactions.setQueued();
|
||||
};
|
||||
queueInitialDiscordAckReaction({
|
||||
enabled: statusReactionsEnabled,
|
||||
shouldSendAckReaction,
|
||||
ackReaction,
|
||||
statusReactions,
|
||||
reactionAdapter: discordAdapter,
|
||||
target: `${messageChannelId}/${message.id}`,
|
||||
});
|
||||
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}`,
|
||||
});
|
||||
};
|
||||
const processContext = await buildDiscordMessageProcessContext({
|
||||
ctx,
|
||||
text,
|
||||
@@ -953,6 +963,7 @@ async function processDiscordMessageInner(
|
||||
storePath: turn.storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession,
|
||||
afterRecord: queueInitialAckReactionAfterRecord,
|
||||
dispatchReplyWithBufferedBlockDispatcher,
|
||||
dispatcherOptions: {
|
||||
...replyPipeline,
|
||||
|
||||
@@ -26,6 +26,12 @@ 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] {
|
||||
@@ -126,15 +132,63 @@ 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" },
|
||||
@@ -145,6 +199,7 @@ describe("maybeCreateDiscordAutoThread autoArchiveDuration", () => {
|
||||
|
||||
it("accepts numeric autoArchiveDuration", async () => {
|
||||
postMock.mockResolvedValueOnce({ id: "thread1" });
|
||||
getMock.mockResolvedValueOnce({});
|
||||
await maybeCreateDiscordAutoThread(
|
||||
createBaseParams({
|
||||
channelConfig: { allowed: true, autoThread: true, autoArchiveDuration: 4320 },
|
||||
@@ -155,6 +210,7 @@ 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);
|
||||
});
|
||||
@@ -163,6 +219,7 @@ 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");
|
||||
|
||||
@@ -193,6 +250,7 @@ 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(
|
||||
@@ -219,6 +277,7 @@ 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");
|
||||
|
||||
@@ -248,6 +307,7 @@ 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");
|
||||
|
||||
@@ -277,6 +337,7 @@ 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" },
|
||||
@@ -289,6 +350,7 @@ 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 },
|
||||
@@ -301,6 +363,7 @@ 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,6 +147,28 @@ 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,6 +8,7 @@ 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";
|
||||
|
||||
@@ -4166,6 +4167,70 @@ 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,6 +466,7 @@ export async function handleFeishuMessage(params: {
|
||||
chatHistories?: Map<string, HistoryEntry[]>;
|
||||
accountId?: string;
|
||||
processingClaimHeld?: boolean;
|
||||
messageDedupeKey?: string;
|
||||
}): Promise<void> {
|
||||
const {
|
||||
cfg,
|
||||
@@ -477,6 +478,7 @@ export async function handleFeishuMessage(params: {
|
||||
chatHistories,
|
||||
accountId,
|
||||
processingClaimHeld = false,
|
||||
messageDedupeKey: messageDedupeKeyOverride,
|
||||
} = params;
|
||||
|
||||
// Resolve account with merged config
|
||||
@@ -487,7 +489,7 @@ export async function handleFeishuMessage(params: {
|
||||
const error = runtime?.error ?? console.error;
|
||||
|
||||
const messageId = event.message.message_id;
|
||||
const messageDedupeKey = resolveFeishuMessageDedupeKey(event);
|
||||
const messageDedupeKey = messageDedupeKeyOverride ?? resolveFeishuMessageDedupeKey(event);
|
||||
if (
|
||||
!(await finalizeFeishuMessageProcessing({
|
||||
messageId: messageDedupeKey,
|
||||
|
||||
90
extensions/feishu/src/dedupe-key.test.ts
Normal file
90
extensions/feishu/src/dedupe-key.test.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
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,10 +1,12 @@
|
||||
// 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">;
|
||||
type FeishuMessageDedupeInput = Pick<FeishuMessageEvent, "message" | "sender">;
|
||||
|
||||
function readExternalKey(value: unknown): string | undefined {
|
||||
return normalizeFeishuExternalKey(typeof value === "string" ? value : "");
|
||||
@@ -57,6 +59,42 @@ 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) {
|
||||
@@ -64,5 +102,11 @@ export function resolveFeishuMessageDedupeKey(event: FeishuMessageDedupeInput):
|
||||
}
|
||||
const messageType = event.message.message_type.trim();
|
||||
const mediaParts = resolveMessageMediaParts(messageType, event.message.content);
|
||||
return mediaParts.length > 0 ? buildMediaDedupeKey(messageId, mediaParts) : messageId;
|
||||
if (mediaParts.length > 0) {
|
||||
return buildMediaDedupeKey(messageId, mediaParts);
|
||||
}
|
||||
if (messageType === "text") {
|
||||
return resolveTextRetryDedupeKey(event) ?? messageId;
|
||||
}
|
||||
return messageId;
|
||||
}
|
||||
|
||||
112
extensions/feishu/src/monitor.message-handler.test.ts
Normal file
112
extensions/feishu/src/monitor.message-handler.test.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// 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,6 +28,7 @@ type FeishuMessageReceiveHandlerContext = {
|
||||
chatHistories?: Map<string, HistoryEntry[]>;
|
||||
accountId?: string;
|
||||
processingClaimHeld?: boolean;
|
||||
messageDedupeKey?: string;
|
||||
}) => Promise<void>;
|
||||
resolveDebounceText: (params: {
|
||||
event: FeishuMessageEvent;
|
||||
@@ -184,7 +185,7 @@ export function createFeishuMessageReceiveHandler({
|
||||
},
|
||||
});
|
||||
|
||||
const dispatchFeishuMessage = async (event: FeishuMessageEvent) => {
|
||||
const dispatchFeishuMessage = async (event: FeishuMessageEvent, messageDedupeKey?: string) => {
|
||||
const sequentialKey = resolveSequentialKey({
|
||||
accountId,
|
||||
event,
|
||||
@@ -202,6 +203,7 @@ export function createFeishuMessageReceiveHandler({
|
||||
chatHistories,
|
||||
accountId,
|
||||
processingClaimHeld: true,
|
||||
messageDedupeKey,
|
||||
});
|
||||
await enqueue(sequentialKey, task);
|
||||
};
|
||||
@@ -266,7 +268,7 @@ export function createFeishuMessageReceiveHandler({
|
||||
return;
|
||||
}
|
||||
if (entries.length === 1) {
|
||||
await dispatchFeishuMessage(last);
|
||||
await dispatchFeishuMessage(last, resolveFeishuMessageDedupeKey(last));
|
||||
return;
|
||||
}
|
||||
const dedupedEntries = dedupeFeishuDebounceEntriesByDedupeKey(entries);
|
||||
@@ -280,10 +282,8 @@ export function createFeishuMessageReceiveHandler({
|
||||
if (!dispatchEntry) {
|
||||
return;
|
||||
}
|
||||
await recordSuppressedMessageIds(
|
||||
dedupedEntries,
|
||||
resolveFeishuMessageDedupeKey(dispatchEntry),
|
||||
);
|
||||
const dispatchDedupeKey = resolveFeishuMessageDedupeKey(dispatchEntry);
|
||||
await recordSuppressedMessageIds(dedupedEntries, dispatchDedupeKey);
|
||||
const combinedText = freshEntries
|
||||
.map((entry) => resolveDebounceText(entry))
|
||||
.filter(Boolean)
|
||||
@@ -292,19 +292,22 @@ 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) {
|
||||
@@ -321,6 +324,14 @@ 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}`);
|
||||
|
||||
28
openclaw.mjs
28
openclaw.mjs
@@ -11,6 +11,8 @@ 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(".");
|
||||
@@ -24,6 +26,15 @@ 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;
|
||||
@@ -194,10 +205,12 @@ const runRespawnedChild = (command, args, env) => {
|
||||
};
|
||||
|
||||
const respawnWithoutCompileCacheIfNeeded = () => {
|
||||
if (!isSourceCheckoutLauncher()) {
|
||||
const needsDisabledCompileCacheRespawn =
|
||||
isSourceCheckoutLauncher() || shouldSkipCompileCacheForWindowsNode24();
|
||||
if (!needsDisabledCompileCacheRespawn) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED === "1") {
|
||||
if (process.env[COMPILE_CACHE_DISABLED_RESPAWNED_ENV] === "1") {
|
||||
return false;
|
||||
}
|
||||
if (!module.getCompileCacheDir?.() && !isNodeCompileCacheRequested()) {
|
||||
@@ -206,7 +219,7 @@ const respawnWithoutCompileCacheIfNeeded = () => {
|
||||
const env = {
|
||||
...process.env,
|
||||
NODE_DISABLE_COMPILE_CACHE: "1",
|
||||
OPENCLAW_SOURCE_COMPILE_CACHE_RESPAWNED: "1",
|
||||
[COMPILE_CACHE_DISABLED_RESPAWNED_ENV]: "1",
|
||||
};
|
||||
delete env.NODE_COMPILE_CACHE;
|
||||
return runRespawnedChild(
|
||||
@@ -217,7 +230,11 @@ const respawnWithoutCompileCacheIfNeeded = () => {
|
||||
};
|
||||
|
||||
const respawnWithPackagedCompileCacheIfNeeded = () => {
|
||||
if (isSourceCheckoutLauncher() || isNodeCompileCacheDisabled()) {
|
||||
if (
|
||||
isSourceCheckoutLauncher() ||
|
||||
isNodeCompileCacheDisabled() ||
|
||||
shouldSkipCompileCacheForWindowsNode24()
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (process.env.OPENCLAW_PACKAGED_COMPILE_CACHE_RESPAWNED === "1") {
|
||||
@@ -251,7 +268,8 @@ if (
|
||||
!waitingForCompileCacheRespawn &&
|
||||
module.enableCompileCache &&
|
||||
!isNodeCompileCacheDisabled() &&
|
||||
!isSourceCheckoutLauncher()
|
||||
!isSourceCheckoutLauncher() &&
|
||||
!shouldSkipCompileCacheForWindowsNode24()
|
||||
) {
|
||||
try {
|
||||
module.enableCompileCache(resolvePackagedCompileCacheDirectory());
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
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,
|
||||
@@ -55,4 +56,209 @@ 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,7 +2,11 @@
|
||||
import type { FileSystem, JsonlSessionMetadata, SessionTreeEntry } from "../types.js";
|
||||
import { SessionError, toError } from "../types.js";
|
||||
import { getFileSystemResultOrThrow } from "./repo-utils.js";
|
||||
import { BaseSessionStorage, leafIdAfterEntry } from "./storage-base.js";
|
||||
import {
|
||||
appendParentIdAfterEntry,
|
||||
BaseSessionStorage,
|
||||
leafIdUpdateAfterEntry,
|
||||
} from "./storage-base.js";
|
||||
import { parseSessionTimestampMs } from "./timestamps.js";
|
||||
|
||||
type JsonlSessionStorageFileSystem = Pick<
|
||||
@@ -113,6 +117,17 @@ 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;
|
||||
}
|
||||
|
||||
@@ -149,6 +164,7 @@ async function loadJsonlStorage(
|
||||
header: SessionHeader;
|
||||
entries: SessionTreeEntry[];
|
||||
leafId: string | null;
|
||||
appendParentId: string | null;
|
||||
}> {
|
||||
const content = getFileSystemResultOrThrow(
|
||||
await fs.readTextFile(filePath),
|
||||
@@ -162,12 +178,17 @@ 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);
|
||||
leafId = leafIdAfterEntry(entry);
|
||||
const leafUpdate = leafIdUpdateAfterEntry(entry);
|
||||
if (leafUpdate !== undefined) {
|
||||
leafId = leafUpdate;
|
||||
}
|
||||
appendParentId = appendParentIdAfterEntry(entry);
|
||||
}
|
||||
return { header, entries, leafId };
|
||||
return { header, entries, leafId, appendParentId };
|
||||
}
|
||||
|
||||
/** Append-only JSONL-backed storage for one session tree. */
|
||||
@@ -181,8 +202,9 @@ export class JsonlSessionStorage extends BaseSessionStorage<JsonlSessionMetadata
|
||||
header: SessionHeader,
|
||||
entries: SessionTreeEntry[],
|
||||
leafId: string | null,
|
||||
appendParentId: string | null,
|
||||
) {
|
||||
super(headerToSessionMetadata(header, filePath), entries, leafId);
|
||||
super(headerToSessionMetadata(header, filePath), entries, leafId, appendParentId);
|
||||
this.fs = fs;
|
||||
this.filePath = filePath;
|
||||
}
|
||||
@@ -192,7 +214,14 @@ 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);
|
||||
return new JsonlSessionStorage(
|
||||
fs,
|
||||
filePath,
|
||||
loaded.header,
|
||||
loaded.entries,
|
||||
loaded.leafId,
|
||||
loaded.appendParentId,
|
||||
);
|
||||
}
|
||||
|
||||
/** Create a new JSONL file with a session header and no entries. */
|
||||
@@ -217,7 +246,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);
|
||||
return new JsonlSessionStorage(fs, filePath, header, [], null, null);
|
||||
}
|
||||
|
||||
override async setLeafId(leafId: string | null): Promise<void> {
|
||||
@@ -230,6 +259,7 @@ 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,6 +2,7 @@
|
||||
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",
|
||||
@@ -60,4 +61,120 @@ 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,6 +122,10 @@ 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);
|
||||
}
|
||||
@@ -157,7 +161,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "message",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
} satisfies MessageEntry);
|
||||
@@ -167,7 +171,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "thinking_level_change",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
} satisfies ThinkingLevelChangeEntry);
|
||||
@@ -177,7 +181,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "model_change",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
@@ -194,7 +198,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "compaction",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
@@ -209,7 +213,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "custom",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
customType,
|
||||
data,
|
||||
@@ -226,7 +230,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "custom_message",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
customType,
|
||||
content,
|
||||
@@ -243,7 +247,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "label",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId,
|
||||
label,
|
||||
@@ -254,7 +258,7 @@ export class Session<TMetadata extends SessionMetadata = SessionMetadata> {
|
||||
return this.appendTypedEntry({
|
||||
type: "session_info",
|
||||
id: await this.storage.createEntryId(),
|
||||
parentId: await this.storage.getLeafId(),
|
||||
parentId: await this.getAppendParentId(),
|
||||
timestamp: new Date().toISOString(),
|
||||
name: name.trim(),
|
||||
} satisfies SessionInfoEntry);
|
||||
|
||||
@@ -28,6 +28,10 @@ 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);
|
||||
@@ -38,19 +42,81 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
return uuidv7();
|
||||
}
|
||||
|
||||
/** 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;
|
||||
/** 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;
|
||||
}
|
||||
|
||||
function resolveLeafId(entries: readonly SessionTreeEntry[]): string | null {
|
||||
let leafId: string | null = null;
|
||||
for (const entry of entries) {
|
||||
leafId = leafIdAfterEntry(entry);
|
||||
const update = leafIdUpdateAfterEntry(entry);
|
||||
if (update !== undefined) {
|
||||
leafId = update;
|
||||
}
|
||||
}
|
||||
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> {
|
||||
@@ -58,21 +124,29 @@ 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> {
|
||||
@@ -86,6 +160,13 @@ 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`);
|
||||
@@ -93,7 +174,7 @@ export abstract class BaseSessionStorage<
|
||||
return {
|
||||
type: "leaf",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId: leafId,
|
||||
};
|
||||
@@ -103,13 +184,40 @@ 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);
|
||||
this.leafId = leafIdAfterEntry(entry);
|
||||
if (leafId !== undefined) {
|
||||
this.leafId = leafId;
|
||||
}
|
||||
this.appendParentId = appendParentIdAfterEntry(entry);
|
||||
}
|
||||
|
||||
async getEntry(id: string): Promise<SessionTreeEntry | undefined> {
|
||||
@@ -137,14 +245,29 @@ export abstract class BaseSessionStorage<
|
||||
if (!current) {
|
||||
throw new SessionError("not_found", `Entry ${leafId} not found`);
|
||||
}
|
||||
const seen = new Set<string>();
|
||||
while (current) {
|
||||
path.unshift(current);
|
||||
if (!current.parentId) {
|
||||
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) {
|
||||
break;
|
||||
}
|
||||
const parent = this.byId.get(current.parentId);
|
||||
const parent = this.byId.get(parentId);
|
||||
if (!parent) {
|
||||
throw new SessionError("invalid_session", `Entry ${current.parentId} not found`);
|
||||
throw new SessionError("invalid_session", `Entry ${parentId} not found`);
|
||||
}
|
||||
current = parent;
|
||||
}
|
||||
|
||||
@@ -374,6 +374,8 @@ 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. */
|
||||
@@ -448,6 +450,8 @@ 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. */
|
||||
@@ -483,6 +487,7 @@ 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,6 +34,7 @@ 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,7 +43,6 @@ 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,13 +40,17 @@ 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 unattended-upgrades. Let that
|
||||
// legitimate maintenance finish instead of racing or disabling the OS service.
|
||||
const APT_LOCK_TIMEOUT_SECONDS = 900;
|
||||
// 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;
|
||||
const BOOTSTRAP_TIMEOUT_SECONDS = 1200;
|
||||
|
||||
function parseOpenClawPackageVersion(value: string): string | null {
|
||||
@@ -445,27 +445,44 @@ 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.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",
|
||||
]);
|
||||
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`);
|
||||
}
|
||||
|
||||
private installLatestRelease(): void {
|
||||
this.guestExec(["curl", "-fsSL", this.options.installUrl, "-o", "/tmp/openclaw-install.sh"]);
|
||||
this.downloadGuestFile(this.options.installUrl, "/tmp/openclaw-install.sh");
|
||||
if (this.options.installVersion) {
|
||||
this.guestExec([
|
||||
"/usr/bin/env",
|
||||
@@ -488,12 +505,22 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
|
||||
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.guestExec(["curl", "-fsSL", tgzUrl, "-o", `/tmp/${tempName}`]);
|
||||
this.downloadGuestFile(tgzUrl, `/tmp/${tempName}`);
|
||||
this.guestExec(["npm", "install", "-g", `/tmp/${tempName}`, "--no-fund", "--no-audit"]);
|
||||
this.guestExec(["openclaw", "--version"]);
|
||||
}
|
||||
|
||||
@@ -24,11 +24,17 @@ 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 \
|
||||
-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_ENV_ARGS[@]}" \
|
||||
"${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"
|
||||
|
||||
115
scripts/lib/release-upgrade-baseline.mjs
Normal file
115
scripts/lib/release-upgrade-baseline.mjs
Normal file
@@ -0,0 +1,115 @@
|
||||
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", 10270),
|
||||
publicExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS", 10271),
|
||||
publicFunctionExports: readBudgetEnv("OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS", 5161),
|
||||
publicDeprecatedExports: readBudgetEnv(
|
||||
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
|
||||
|
||||
@@ -103,23 +103,3 @@ 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;
|
||||
}
|
||||
|
||||
@@ -14,6 +14,7 @@ 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,
|
||||
@@ -67,8 +68,7 @@ export function resolveAuthProfileDatabasePath(agentDir?: string): string {
|
||||
|
||||
/** Resolves the SQLite database and sidecar paths used by auth profiles. */
|
||||
export function resolveAuthProfileDatabaseFilePaths(agentDir?: string): string[] {
|
||||
const databasePath = resolveAuthProfileDatabasePath(agentDir);
|
||||
return [databasePath, `${databasePath}-wal`, `${databasePath}-shm`];
|
||||
return resolveSqliteDatabaseFilePaths(resolveAuthProfileDatabasePath(agentDir));
|
||||
}
|
||||
|
||||
// Read-only probes must tolerate old/corrupt/missing rows. Coercion happens
|
||||
|
||||
@@ -323,11 +323,6 @@ 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();
|
||||
|
||||
@@ -7,6 +7,10 @@ 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,
|
||||
@@ -44,36 +48,18 @@ 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(
|
||||
entries: AgentSessionEntry[],
|
||||
leafId: string | undefined,
|
||||
tree: SessionTranscriptTree<AgentSessionEntry>,
|
||||
leafId: string | null | 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;
|
||||
@@ -82,26 +68,22 @@ function buildSessionBranchEntries(
|
||||
return undefined;
|
||||
}
|
||||
seen.add(currentId);
|
||||
const entry = byId.get(currentId);
|
||||
if (!entry) {
|
||||
const node = tree.byId.get(currentId);
|
||||
if (!node) {
|
||||
return undefined;
|
||||
}
|
||||
branch.push(entry);
|
||||
currentId = readSessionEntryParentId(entry) ?? 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;
|
||||
}
|
||||
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" &&
|
||||
@@ -127,24 +109,27 @@ export async function readBtwTranscriptMessages(params: {
|
||||
const sessionEntries = entries.filter(
|
||||
(entry): entry is AgentSessionEntry => entry.type !== "session",
|
||||
);
|
||||
if (!hasParentLinkedEntries(sessionEntries)) {
|
||||
const tree = scanSessionTranscriptTree(sessionEntries);
|
||||
if (!tree.hasLeafUpdate) {
|
||||
return buildSessionContext(sessionEntries).messages;
|
||||
}
|
||||
|
||||
let branchEntries = params.snapshotLeafId
|
||||
? buildSessionBranchEntries(sessionEntries, params.snapshotLeafId)
|
||||
const hasSnapshotLeaf = params.snapshotLeafId !== undefined;
|
||||
let branchEntries = hasSnapshotLeaf
|
||||
? buildSessionBranchEntries(tree, params.snapshotLeafId)
|
||||
: undefined;
|
||||
if (params.snapshotLeafId && !branchEntries) {
|
||||
if (hasSnapshotLeaf && branchEntries === undefined) {
|
||||
diag.debug(
|
||||
`btw snapshot leaf unavailable: sessionId=${params.sessionId} leaf=${params.snapshotLeafId}`,
|
||||
);
|
||||
}
|
||||
branchEntries ??= buildSessionBranchEntries(sessionEntries, readDefaultLeafId(sessionEntries));
|
||||
if (!params.snapshotLeafId && isTrailingUserMessage(branchEntries?.at(-1))) {
|
||||
branchEntries ??= buildSessionBranchEntries(tree, tree.leafId);
|
||||
if (!hasSnapshotLeaf && 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 parentId = readSessionEntryParentId(branchEntries!.at(-1)!);
|
||||
branchEntries = parentId ? (buildSessionBranchEntries(sessionEntries, parentId) ?? []) : [];
|
||||
const trailingId = readSessionEntryId(branchEntries!.at(-1)!);
|
||||
const parentId = trailingId ? tree.byId.get(trailingId)?.parentId : null;
|
||||
branchEntries = parentId ? (buildSessionBranchEntries(tree, parentId) ?? []) : [];
|
||||
}
|
||||
const sessionContext = buildSessionContext(branchEntries ?? sessionEntries);
|
||||
return Array.isArray(sessionContext.messages) ? sessionContext.messages : [];
|
||||
|
||||
@@ -1482,6 +1482,144 @@ 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,6 +204,65 @@ 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,6 +8,7 @@ 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";
|
||||
@@ -329,7 +330,8 @@ async function loadCliSessionEntries(params: {
|
||||
}
|
||||
const entries = parseSessionEntries(await fsp.readFile(realSessionFile, "utf-8"));
|
||||
migrateSessionEntries(entries);
|
||||
return entries.filter((entry) => entry.type !== "session");
|
||||
const sessionEntries = entries.filter((entry) => entry.type !== "session");
|
||||
return selectSessionTranscriptLeafControlledPath(sessionEntries) ?? sessionEntries;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
@@ -312,6 +312,43 @@ 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) {
|
||||
@@ -338,14 +375,7 @@ describe("runEmbeddedAgent", () => {
|
||||
list: [{ id: "research", model: "openrouter/research-default" }],
|
||||
},
|
||||
};
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeEmbeddedRunnerAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildEmbeddedRunnerAssistant({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mockSuccessfulEmbeddedAttempt();
|
||||
|
||||
await runEmbeddedAgent({
|
||||
sessionId: "configured-default-model",
|
||||
@@ -383,14 +413,7 @@ describe("runEmbeddedAgent", () => {
|
||||
},
|
||||
};
|
||||
setRuntimeConfigSnapshot(cfg);
|
||||
runEmbeddedAttemptMock.mockResolvedValueOnce(
|
||||
makeEmbeddedRunnerAttempt({
|
||||
assistantTexts: ["ok"],
|
||||
lastAssistant: buildEmbeddedRunnerAssistant({
|
||||
content: [{ type: "text", text: "ok" }],
|
||||
}),
|
||||
}),
|
||||
);
|
||||
mockSuccessfulEmbeddedAttempt();
|
||||
|
||||
await runEmbeddedAgent({
|
||||
sessionId: "runtime-config-default-model",
|
||||
@@ -415,6 +438,85 @@ 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,7 +11,8 @@ import {
|
||||
captureCompactionCheckpointSnapshotAsync,
|
||||
cleanupCompactionCheckpointSnapshot,
|
||||
persistSessionCompactionCheckpoint,
|
||||
readSessionLeafIdFromTranscriptAsync,
|
||||
readSessionLeafStateFromTranscriptAsync,
|
||||
resolveCompactionCheckpointTranscriptPosition,
|
||||
resolveSessionCompactionCheckpointReason,
|
||||
type CapturedCompactionCheckpointSnapshot,
|
||||
} from "../../gateway/session-compaction-checkpoints.js";
|
||||
@@ -452,10 +453,12 @@ export async function compactEmbeddedAgentSession(
|
||||
}
|
||||
if (params.config && params.sessionKey && checkpointSnapshot) {
|
||||
try {
|
||||
const postLeafId =
|
||||
postCompactionLeafId ??
|
||||
(await readSessionLeafIdFromTranscriptAsync(postCompactionSessionFile)) ??
|
||||
undefined;
|
||||
const transcriptState =
|
||||
await readSessionLeafStateFromTranscriptAsync(postCompactionSessionFile);
|
||||
const checkpointPosition = resolveCompactionCheckpointTranscriptPosition({
|
||||
preferredLeafId: postCompactionLeafId,
|
||||
transcriptState,
|
||||
});
|
||||
const storedCheckpoint = await persistSessionCompactionCheckpoint({
|
||||
cfg: params.config,
|
||||
sessionKey: params.sessionKey,
|
||||
@@ -469,8 +472,8 @@ export async function compactEmbeddedAgentSession(
|
||||
tokensBefore: result.result?.tokensBefore,
|
||||
tokensAfter: result.result?.tokensAfter,
|
||||
postSessionFile: postCompactionSessionFile,
|
||||
postLeafId,
|
||||
postEntryId: postLeafId,
|
||||
postLeafId: checkpointPosition.leafId,
|
||||
postEntryId: checkpointPosition.entryId,
|
||||
});
|
||||
checkpointSnapshotRetained = storedCheckpoint !== null;
|
||||
} catch (err) {
|
||||
|
||||
@@ -11,6 +11,8 @@ import {
|
||||
captureCompactionCheckpointSnapshotAsync,
|
||||
cleanupCompactionCheckpointSnapshot,
|
||||
persistSessionCompactionCheckpoint,
|
||||
readSessionLeafStateFromTranscriptAsync,
|
||||
resolveCompactionCheckpointTranscriptPosition,
|
||||
resolveSessionCompactionCheckpointReason,
|
||||
type CapturedCompactionCheckpointSnapshot,
|
||||
} from "../../gateway/session-compaction-checkpoints.js";
|
||||
@@ -1504,6 +1506,12 @@ 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,
|
||||
@@ -1517,8 +1525,8 @@ async function compactEmbeddedAgentSessionDirectOnce(
|
||||
tokensBefore: observedTokenCount ?? result.tokensBefore,
|
||||
tokensAfter,
|
||||
postSessionFile: activeSessionFile,
|
||||
postLeafId: activePostLeafId,
|
||||
postEntryId: activePostLeafId,
|
||||
postLeafId: checkpointPosition.leafId,
|
||||
postEntryId: checkpointPosition.entryId,
|
||||
createdAt: compactStartedAt,
|
||||
});
|
||||
checkpointSnapshotRetained = storedCheckpoint !== null;
|
||||
|
||||
@@ -132,6 +132,9 @@ 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);
|
||||
@@ -167,6 +170,9 @@ 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);
|
||||
@@ -203,6 +209,9 @@ 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);
|
||||
@@ -236,6 +245,9 @@ 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,7 +100,11 @@ import {
|
||||
resolveAuthProfileOrder,
|
||||
shouldPreferExplicitConfigApiKeyAuth,
|
||||
} from "../model-auth.js";
|
||||
import { resolveDefaultModelForAgent } from "../model-selection.js";
|
||||
import {
|
||||
buildModelAliasIndex,
|
||||
resolveDefaultModelForAgent,
|
||||
resolveModelRefFromString,
|
||||
} from "../model-selection.js";
|
||||
import { resolveThinkingDefault } from "../model-thinking-default.js";
|
||||
import { ensureOpenClawModelsJson } from "../models-config.js";
|
||||
import {
|
||||
@@ -528,6 +532,49 @@ 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> {
|
||||
@@ -758,17 +805,12 @@ async function runEmbeddedAgentInternal(
|
||||
startupStages.mark("runtime-plugins");
|
||||
notifyExecutionPhase("runtime_plugins");
|
||||
|
||||
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;
|
||||
let { provider, modelId } = resolveInitialEmbeddedRunModel({
|
||||
config: params.config,
|
||||
agentId: workspaceResolution.agentId,
|
||||
provider: params.provider,
|
||||
model: params.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,6 +1,7 @@
|
||||
/**
|
||||
* 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";
|
||||
@@ -180,47 +181,51 @@ export function stripSessionsYieldArtifacts(activeSession: {
|
||||
|
||||
const sessionManager = activeSession.sessionManager as
|
||||
| {
|
||||
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;
|
||||
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;
|
||||
}
|
||||
| undefined;
|
||||
const fileEntries = sessionManager?.fileEntries;
|
||||
const byId = sessionManager?.byId;
|
||||
if (!fileEntries || !byId) {
|
||||
if (typeof sessionManager?.removeTrailingEntries !== "function") {
|
||||
return;
|
||||
}
|
||||
|
||||
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?.();
|
||||
}
|
||||
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)),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ type SessionManagerMocks = {
|
||||
appendCustomEntry: UnknownMock;
|
||||
flushPendingToolResults: UnknownMock;
|
||||
clearPendingToolResults: UnknownMock;
|
||||
removeTrailingEntries: UnknownMock;
|
||||
};
|
||||
type AttemptSpawnWorkspaceHoisted = {
|
||||
spawnSubagentDirectMock: UnknownMock;
|
||||
@@ -208,6 +209,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
|
||||
appendCustomEntry: vi.fn(),
|
||||
flushPendingToolResults: vi.fn(),
|
||||
clearPendingToolResults: vi.fn(),
|
||||
removeTrailingEntries: vi.fn(() => 0),
|
||||
};
|
||||
return {
|
||||
spawnSubagentDirectMock,
|
||||
|
||||
@@ -21,6 +21,7 @@ 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";
|
||||
@@ -66,6 +67,7 @@ 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 {
|
||||
@@ -699,42 +701,27 @@ function removeTrailingMidTurnPrecheckAssistantError(params: {
|
||||
sessionManager: ReturnType<typeof guardSessionManager>;
|
||||
}): void {
|
||||
const messages = params.activeSession.agent.state.messages;
|
||||
if (isMidTurnPrecheckAssistantError(messages.at(-1))) {
|
||||
const removedActiveError = isMidTurnPrecheckAssistantError(messages.at(-1));
|
||||
if (removedActiveError) {
|
||||
params.activeSession.agent.state.messages = messages.slice(0, -1);
|
||||
}
|
||||
|
||||
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") {
|
||||
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 SessionManager rewrite hook is unavailable",
|
||||
"[context-overflow-midturn-precheck] removed synthetic assistant error from active session but could not locate matching persisted SessionManager entry",
|
||||
);
|
||||
return;
|
||||
}
|
||||
mutableSessionManager.fileEntries?.pop();
|
||||
if (lastEntry.id) {
|
||||
mutableSessionManager.byId?.delete(lastEntry.id);
|
||||
}
|
||||
mutableSessionManager.leafId = lastEntry.parentId ?? null;
|
||||
mutableSessionManager.rewriteFile();
|
||||
}
|
||||
|
||||
function collectAttemptExplicitToolAllowlistSources(params: {
|
||||
@@ -2115,12 +2102,25 @@ 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?: { publishOwnedWrite?: boolean },
|
||||
options?: OwnedSessionTranscriptWriteOptions<T>,
|
||||
) => sessionLockController.withSessionWriteLock(operation, options),
|
||||
};
|
||||
const withOwnedSessionWriteLock = <T>(operation: () => Promise<T> | T): Promise<T> =>
|
||||
@@ -2141,7 +2141,6 @@ 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,6 +71,63 @@ 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.
|
||||
@@ -154,6 +211,85 @@ 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,7 +5,12 @@ 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 };
|
||||
type SessionHeaderEntry = {
|
||||
type: "session";
|
||||
id?: string;
|
||||
cwd?: string;
|
||||
parentSession?: string;
|
||||
};
|
||||
type SessionMessageEntry = { type: "message"; message?: { role?: string } };
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
@@ -58,6 +63,8 @@ export async function prepareSessionManagerForRun(params: {
|
||||
labelsById?: Map<string, unknown>;
|
||||
leafId?: string | null;
|
||||
wasRecoveredFromCorruptHeader?: () => boolean;
|
||||
clearPreservedOpaqueFileEntries?: () => void;
|
||||
getSerializedFileLinesForRewrite?: () => string[];
|
||||
syncSnapshotAfterHeaderRewrite?: (expectedContent?: string) => void;
|
||||
};
|
||||
|
||||
@@ -75,14 +82,18 @@ export async function prepareSessionManagerForRun(params: {
|
||||
}
|
||||
|
||||
if (params.hadSessionFile && header && !hasAssistant) {
|
||||
if (sm.wasRecoveredFromCorruptHeader?.()) {
|
||||
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.
|
||||
header.id = params.sessionId;
|
||||
header.cwd = params.cwd;
|
||||
sm.sessionId = params.sessionId;
|
||||
sm.cwd = params.cwd;
|
||||
const content = await writeJsonlLines(
|
||||
params.sessionFile,
|
||||
sm.fileEntries.map(serializeJsonlLine),
|
||||
sm.getSerializedFileLinesForRewrite?.() ?? sm.fileEntries.map(serializeJsonlLine),
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
@@ -101,6 +112,7 @@ 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;
|
||||
@@ -120,7 +132,7 @@ export async function prepareSessionManagerForRun(params: {
|
||||
}
|
||||
const content = await writeJsonlLines(
|
||||
params.sessionFile,
|
||||
sm.fileEntries.map(serializeJsonlLine),
|
||||
sm.getSerializedFileLinesForRewrite?.() ?? sm.fileEntries.map(serializeJsonlLine),
|
||||
{
|
||||
mode: 0o600,
|
||||
},
|
||||
|
||||
@@ -67,7 +67,6 @@ 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,7 +4,10 @@ import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { readTranscriptFileState } from "./transcript-file-state.js";
|
||||
import {
|
||||
persistTranscriptStateMutation,
|
||||
readTranscriptFileState,
|
||||
} from "./transcript-file-state.js";
|
||||
import { rewriteTranscriptEntriesInState } from "./transcript-rewrite.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
@@ -460,6 +463,54 @@ 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");
|
||||
@@ -844,6 +895,55 @@ 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");
|
||||
@@ -970,6 +1070,333 @@ 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,6 +4,7 @@
|
||||
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";
|
||||
@@ -29,6 +30,18 @@ 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",
|
||||
@@ -276,16 +289,73 @@ 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 readableSessionEntries(fileEntries: FileEntry[]): SessionEntry[] {
|
||||
function readableSessionState(fileEntries: FileEntry[]): ReadableSessionState {
|
||||
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;
|
||||
@@ -384,13 +454,62 @@ function readableSessionEntries(fileEntries: FileEntry[]): SessionEntry[] {
|
||||
if (!isRecord(rawEntry)) {
|
||||
continue;
|
||||
}
|
||||
const rawRecord = rawEntry as unknown as Record<string, unknown>;
|
||||
const entry = rawEntry as FileEntry;
|
||||
const id = rawEntry.id;
|
||||
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;
|
||||
}
|
||||
if (!isSessionEntry(entry)) {
|
||||
if (isString(id)) {
|
||||
rejectedIds.add(id);
|
||||
const parentId = rawEntry.parentId;
|
||||
rejectedParentById.set(id, isString(parentId) ? parentId : null);
|
||||
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;
|
||||
}
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -402,12 +521,35 @@ function readableSessionEntries(fileEntries: FileEntry[]): SessionEntry[] {
|
||||
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;
|
||||
return {
|
||||
entries,
|
||||
leafId: effectiveLeafId,
|
||||
appendParentId: effectiveAppendParentId,
|
||||
...(effectiveAppendMode ? { appendMode: effectiveAppendMode } : {}),
|
||||
opaqueParentsById: rejectedParentById,
|
||||
logicalParentsById,
|
||||
};
|
||||
}
|
||||
|
||||
function sessionHeaderVersion(header: SessionHeader | null): number {
|
||||
@@ -424,7 +566,7 @@ function generateEntryId(byId: { has(id: string): boolean }): string {
|
||||
return randomUUID();
|
||||
}
|
||||
|
||||
function serializeTranscriptFileEntries(entries: FileEntry[]): string {
|
||||
function serializeTranscriptFileEntries(entries: readonly unknown[]): string {
|
||||
return `${entries.map((entry) => JSON.stringify(entry)).join("\n")}\n`;
|
||||
}
|
||||
|
||||
@@ -448,27 +590,58 @@ 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;
|
||||
this.rebuildIndex();
|
||||
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;
|
||||
}
|
||||
|
||||
private rebuildIndex(): void {
|
||||
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 {
|
||||
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);
|
||||
@@ -479,6 +652,14 @@ export class TranscriptFileState {
|
||||
}
|
||||
}
|
||||
}
|
||||
if (leafId !== undefined) {
|
||||
this.leafId = leafId;
|
||||
}
|
||||
if (appendParentId !== undefined) {
|
||||
this.appendParentId = appendParentId;
|
||||
} else if (leafId !== undefined) {
|
||||
this.appendParentId = leafId;
|
||||
}
|
||||
}
|
||||
|
||||
getCwd(): string {
|
||||
@@ -497,6 +678,14 @@ 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;
|
||||
}
|
||||
@@ -507,17 +696,34 @@ export class TranscriptFileState {
|
||||
|
||||
getBranch(fromId?: string): SessionEntry[] {
|
||||
const branch: SessionEntry[] = [];
|
||||
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;
|
||||
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;
|
||||
}
|
||||
branch.reverse();
|
||||
return branch;
|
||||
}
|
||||
|
||||
buildSessionContext(): SessionContext {
|
||||
return buildSessionContext(this.entries, this.leafId, this.byId);
|
||||
const entries = this.getBranch();
|
||||
const leafId = entries.at(-1)?.id ?? null;
|
||||
return buildSessionContext(entries, leafId, new Map(entries.map((entry) => [entry.id, entry])));
|
||||
}
|
||||
|
||||
/** Move the active leaf to an existing entry without appending a row. */
|
||||
@@ -526,18 +732,22 @@ 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.leafId,
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
message,
|
||||
});
|
||||
@@ -547,7 +757,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "thinking_level_change",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
thinkingLevel,
|
||||
});
|
||||
@@ -557,7 +767,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "model_change",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
provider,
|
||||
modelId,
|
||||
@@ -574,7 +784,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "compaction",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
summary,
|
||||
firstKeptEntryId,
|
||||
@@ -590,7 +800,7 @@ export class TranscriptFileState {
|
||||
customType,
|
||||
data,
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -599,7 +809,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "session_info",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
name: name.trim(),
|
||||
});
|
||||
@@ -618,7 +828,7 @@ export class TranscriptFileState {
|
||||
display,
|
||||
details,
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
@@ -630,7 +840,7 @@ export class TranscriptFileState {
|
||||
return this.appendEntry({
|
||||
type: "label",
|
||||
id: generateEntryId(this.byId),
|
||||
parentId: this.leafId,
|
||||
parentId: this.appendParentId,
|
||||
timestamp: new Date().toISOString(),
|
||||
targetId,
|
||||
label,
|
||||
@@ -647,6 +857,7 @@ 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),
|
||||
@@ -659,10 +870,58 @@ 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.leafId = entry.id;
|
||||
this.appendParentId = entry.id;
|
||||
if (isSessionTranscriptSideAppendEntry(entry)) {
|
||||
this.appendMode = "side";
|
||||
} else {
|
||||
this.leafId = entry.id;
|
||||
this.appendMode = undefined;
|
||||
}
|
||||
if (entry.type === "label") {
|
||||
if (entry.label) {
|
||||
this.labelsById.set(entry.targetId, entry.label);
|
||||
@@ -687,14 +946,23 @@ export async function readTranscriptFileState(sessionFile: string): Promise<Tran
|
||||
migrateSessionEntries(fileEntries);
|
||||
const header =
|
||||
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
|
||||
const entries = readableSessionEntries(fileEntries);
|
||||
return new TranscriptFileState({ header, entries, migrated });
|
||||
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,
|
||||
});
|
||||
}
|
||||
|
||||
/** Rewrite the full transcript through the private-file store. */
|
||||
export async function writeTranscriptFileAtomic(
|
||||
filePath: string,
|
||||
entries: Array<SessionHeader | SessionEntry>,
|
||||
entries: Array<SessionHeader | TranscriptPersistedEntry>,
|
||||
): Promise<void> {
|
||||
await privateFileStore(path.dirname(filePath)).writeText(
|
||||
path.basename(filePath),
|
||||
@@ -706,15 +974,19 @@ export async function writeTranscriptFileAtomic(
|
||||
export async function persistTranscriptStateMutation(params: {
|
||||
sessionFile: string;
|
||||
state: TranscriptFileState;
|
||||
appendedEntries: SessionEntry[];
|
||||
appendedEntries: TranscriptPersistedEntry[];
|
||||
}): 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,6 +397,185 @@ 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,6 +21,7 @@ import {
|
||||
persistTranscriptStateMutation,
|
||||
readTranscriptFileState,
|
||||
type TranscriptFileState,
|
||||
type TranscriptPersistedEntry,
|
||||
} from "./transcript-file-state.js";
|
||||
import {
|
||||
persistRuntimeTranscriptStateMutation,
|
||||
@@ -261,7 +262,7 @@ export function rewriteTranscriptEntriesInState(params: {
|
||||
state: TranscriptFileState;
|
||||
replacements: TranscriptRewriteReplacement[];
|
||||
allowedRewriteSuffixEntryIds?: string[];
|
||||
}): TranscriptRewriteResult & { appendedEntries: SessionBranchEntry[] } {
|
||||
}): TranscriptRewriteResult & { appendedEntries: TranscriptPersistedEntry[] } {
|
||||
const replacementsById = new Map(
|
||||
params.replacements
|
||||
.filter((replacement) => replacement.entryId.trim().length > 0)
|
||||
@@ -277,7 +278,58 @@ export function rewriteTranscriptEntriesInState(params: {
|
||||
};
|
||||
}
|
||||
|
||||
const branch = params.state.getBranch();
|
||||
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: [],
|
||||
};
|
||||
}
|
||||
if (branch.length === 0) {
|
||||
return {
|
||||
changed: false,
|
||||
@@ -351,7 +403,7 @@ export function rewriteTranscriptEntriesInState(params: {
|
||||
params.state.branch(firstMatchedEntry.parentId);
|
||||
}
|
||||
|
||||
const appendedEntries: SessionBranchEntry[] = [];
|
||||
const appendedEntries: TranscriptPersistedEntry[] = [];
|
||||
const rewrittenEntryIds = new Map<string, string>();
|
||||
for (let index = matchedIndices[0]; index < branch.length; index++) {
|
||||
const entry = branch[index];
|
||||
@@ -367,6 +419,15 @@ 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,6 +12,7 @@ import {
|
||||
persistTranscriptStateMutation,
|
||||
readTranscriptFileState,
|
||||
type TranscriptFileState,
|
||||
type TranscriptPersistedEntry,
|
||||
writeTranscriptFileAtomic,
|
||||
} from "./transcript-file-state.js";
|
||||
|
||||
@@ -60,7 +61,7 @@ export async function readRuntimeTranscriptState(
|
||||
* Persists an append or migration rewrite for a resolved runtime transcript.
|
||||
*/
|
||||
export async function persistRuntimeTranscriptStateMutation(params: {
|
||||
appendedEntries: SessionEntry[];
|
||||
appendedEntries: TranscriptPersistedEntry[];
|
||||
state: TranscriptFileState;
|
||||
target: RuntimeTranscriptTarget;
|
||||
}): Promise<void> {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
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,13 +972,21 @@ 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(extracted.threadId),
|
||||
threadImplicit: extracted.threadImplicit === true ? true : undefined,
|
||||
threadSuppressed: extracted.threadSuppressed === true ? true : undefined,
|
||||
threadId: normalizeOptionalString(threadEvidence.threadId),
|
||||
threadImplicit: threadEvidence.threadImplicit === true ? true : undefined,
|
||||
threadSuppressed: threadEvidence.threadSuppressed === true ? true : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -784,6 +784,120 @@ 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([
|
||||
{
|
||||
@@ -835,6 +949,36 @@ 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,6 +5,7 @@
|
||||
*/
|
||||
import {
|
||||
hasNonEmptyString as hasNonEmptyStringField,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
readStringValue,
|
||||
} from "@openclaw/normalization-core/string-coerce";
|
||||
@@ -27,6 +28,7 @@ type RawToolCallBlock = {
|
||||
name?: unknown;
|
||||
input?: unknown;
|
||||
arguments?: unknown;
|
||||
partialJson?: unknown;
|
||||
};
|
||||
|
||||
const RAW_TOOL_CALL_BLOCK_TYPES = new Set([
|
||||
@@ -72,6 +74,45 @@ 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
|
||||
@@ -116,6 +157,7 @@ function isReplaySafeThinkingAssistantTurn(
|
||||
const toolCallId = typeof block.id === "string" ? block.id.trim() : "";
|
||||
if (
|
||||
!hasToolCallInput(block) ||
|
||||
hasPartialJson(block) ||
|
||||
!toolCallId ||
|
||||
seenToolCallIds.has(toolCallId) ||
|
||||
!isAllowedToolCallName(block.name, allowedToolNames)
|
||||
@@ -382,31 +424,72 @@ function repairToolCallInputs(
|
||||
let messageChanged = false;
|
||||
|
||||
for (const block of msg.content) {
|
||||
if (
|
||||
isRawToolCallBlock(block) &&
|
||||
(!hasToolCallInput(block) ||
|
||||
!hasToolCallId(block) ||
|
||||
!isAllowedToolCallName((block as RawToolCallBlock).name, allowedToolNames))
|
||||
) {
|
||||
droppedToolCalls += 1;
|
||||
droppedInMessage += 1;
|
||||
changed = true;
|
||||
messageChanged = true;
|
||||
continue;
|
||||
}
|
||||
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);
|
||||
// Drop genuinely incomplete streaming artifacts (missing required fields).
|
||||
if (
|
||||
!hasToolCallInput(block) ||
|
||||
!hasToolCallId(block) ||
|
||||
!isAllowedToolCallName((block as RawToolCallBlock).name, allowedToolNames)
|
||||
) {
|
||||
droppedToolCalls += 1;
|
||||
droppedInMessage += 1;
|
||||
changed = true;
|
||||
messageChanged = true;
|
||||
continue;
|
||||
}
|
||||
} else {
|
||||
nextContent.push(block);
|
||||
}
|
||||
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;
|
||||
changed = true;
|
||||
messageChanged = true;
|
||||
}
|
||||
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);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
}
|
||||
nextContent.push(workBlock);
|
||||
}
|
||||
|
||||
if (droppedInMessage > 0) {
|
||||
|
||||
@@ -6,6 +6,7 @@ import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { withOwnedSessionTranscriptWrites } from "../../config/sessions/transcript-write-context.js";
|
||||
import { isTranscriptOnlyOpenClawAssistantMessage } from "../../shared/transcript-only-openclaw-assistant.js";
|
||||
import { prepareSessionManagerForRun } from "../embedded-agent-runner/session-manager-init.js";
|
||||
import { repairSessionFileIfNeeded } from "../session-file-repair.js";
|
||||
import {
|
||||
@@ -70,6 +71,7 @@ describe("SessionManager.open", () => {
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, "/tmp/task-repo");
|
||||
|
||||
expect(sessionManager.getEntries()).toEqual([userEntry, assistantEntry]);
|
||||
expect(sessionManager.getChildren(userEntry.id)).toEqual([assistantEntry]);
|
||||
expect(await fs.readFile(sessionFile, "utf8")).toContain("important question");
|
||||
expect(await fs.readFile(sessionFile, "utf8")).toContain("important answer");
|
||||
await expect(fs.readFile(sessionFile, "utf8")).resolves.not.toBe(originalTranscript);
|
||||
@@ -1322,6 +1324,923 @@ describe("SessionManager.open", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves opaque transcript rows during embedded header normalization", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const metadata = { type: "metadata", payload: { source: "plugin" } };
|
||||
const assistantEntry = {
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-04T00:00:01.000Z",
|
||||
message: { role: "assistant", content: "carried context" },
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
JSON.stringify(buildSessionHeader(dir, "original-session")),
|
||||
JSON.stringify(metadata),
|
||||
JSON.stringify(assistantEntry),
|
||||
].join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, dir);
|
||||
await prepareSessionManagerForRun({
|
||||
sessionManager,
|
||||
sessionFile,
|
||||
hadSessionFile: true,
|
||||
sessionId: "run-session",
|
||||
cwd: "/tmp/task-repo",
|
||||
});
|
||||
|
||||
const records = (await fs.readFile(sessionFile, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as unknown);
|
||||
expect(records).toContainEqual(metadata);
|
||||
expect(sessionManager.getEntries()).toEqual([assistantEntry]);
|
||||
});
|
||||
|
||||
it("bridges parent-linked opaque rows without exposing them as session entries", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const userEntry = {
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-04T00:00:01.000Z",
|
||||
message: { role: "user", content: "question" },
|
||||
};
|
||||
const metadata = {
|
||||
type: "metadata",
|
||||
id: "metadata-1",
|
||||
parentId: userEntry.id,
|
||||
payload: { source: "plugin" },
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[buildSessionHeader(dir, "session-1"), userEntry, metadata]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, dir);
|
||||
expect(sessionManager.getLeafEntry()).toEqual(userEntry);
|
||||
const assistantId = sessionManager.appendMessage(buildAssistantMessage("answer"));
|
||||
const assistantEntry = sessionManager.getEntry(assistantId);
|
||||
|
||||
expect(assistantEntry).toEqual(expect.objectContaining({ parentId: userEntry.id }));
|
||||
const persistedAssistant = (await fs.readFile(sessionFile, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { id?: string; parentId?: string | null })
|
||||
.find((entry) => entry.id === assistantId);
|
||||
expect(persistedAssistant).toEqual(expect.objectContaining({ parentId: metadata.id }));
|
||||
expect(sessionManager.getEntries()).toEqual([userEntry, assistantEntry]);
|
||||
expect(sessionManager.getBranch()).toEqual([
|
||||
userEntry,
|
||||
expect.objectContaining({ id: assistantId, parentId: userEntry.id }),
|
||||
]);
|
||||
expect(sessionManager.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "answer" }] },
|
||||
]);
|
||||
|
||||
sessionManager.branch(metadata.id);
|
||||
expect(sessionManager.getLeafId()).toBe(userEntry.id);
|
||||
sessionManager.branch(assistantId);
|
||||
const branchedFile = sessionManager.createBranchedSession(assistantId);
|
||||
expect(branchedFile).toBeDefined();
|
||||
const branchedRecords = (await fs.readFile(branchedFile!, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { id?: string; parentId?: string | null });
|
||||
expect(branchedRecords).toContainEqual(metadata);
|
||||
expect(branchedRecords.find((record) => record.id === assistantId)?.parentId).toBe(metadata.id);
|
||||
expect(
|
||||
SessionManager.open(branchedFile!, dir, dir).buildSessionContext().messages,
|
||||
).toMatchObject([
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "answer" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("repairs compaction boundaries that point through opaque rows", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const userEntry = {
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-04T00:00:01.000Z",
|
||||
message: { role: "user", content: "question" },
|
||||
};
|
||||
const metadata = {
|
||||
type: "metadata",
|
||||
id: "metadata-1",
|
||||
parentId: userEntry.id,
|
||||
payload: { source: "plugin" },
|
||||
};
|
||||
const assistantEntry = {
|
||||
type: "message",
|
||||
id: "assistant-1",
|
||||
parentId: metadata.id,
|
||||
timestamp: "2026-06-04T00:00:02.000Z",
|
||||
message: buildAssistantMessage("answer"),
|
||||
};
|
||||
const compactionEntry = {
|
||||
type: "compaction",
|
||||
id: "compaction-1",
|
||||
parentId: assistantEntry.id,
|
||||
timestamp: "2026-06-04T00:00:03.000Z",
|
||||
summary: "summary",
|
||||
firstKeptEntryId: metadata.id,
|
||||
tokensBefore: 200,
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[buildSessionHeader(dir, "session-1"), userEntry, metadata, assistantEntry, compactionEntry]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, dir);
|
||||
|
||||
expect(sessionManager.getEntry(compactionEntry.id)).toEqual(
|
||||
expect.objectContaining({ firstKeptEntryId: userEntry.id }),
|
||||
);
|
||||
expect(sessionManager.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "compactionSummary", summary: "summary" },
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "answer" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("repairs opaque compaction boundaries on the active branch", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const opaqueRoot = { type: "metadata", id: "opaque-root", parentId: null };
|
||||
const branchAUser = {
|
||||
type: "message",
|
||||
id: "branch-a-user",
|
||||
parentId: opaqueRoot.id,
|
||||
timestamp: "2026-06-04T00:00:01.000Z",
|
||||
message: { role: "user", content: "branch a" },
|
||||
};
|
||||
const branchBUser = {
|
||||
type: "message",
|
||||
id: "branch-b-user",
|
||||
parentId: opaqueRoot.id,
|
||||
timestamp: "2026-06-04T00:00:02.000Z",
|
||||
message: { role: "user", content: "branch b" },
|
||||
};
|
||||
const branchBAssistant = {
|
||||
type: "message",
|
||||
id: "branch-b-assistant",
|
||||
parentId: branchBUser.id,
|
||||
timestamp: "2026-06-04T00:00:03.000Z",
|
||||
message: buildAssistantMessage("branch b answer"),
|
||||
};
|
||||
const compactionEntry = {
|
||||
type: "compaction",
|
||||
id: "compaction-1",
|
||||
parentId: branchBAssistant.id,
|
||||
timestamp: "2026-06-04T00:00:04.000Z",
|
||||
summary: "summary",
|
||||
firstKeptEntryId: opaqueRoot.id,
|
||||
tokensBefore: 200,
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
buildSessionHeader(dir, "session-1"),
|
||||
opaqueRoot,
|
||||
branchAUser,
|
||||
branchBUser,
|
||||
branchBAssistant,
|
||||
compactionEntry,
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, dir);
|
||||
|
||||
expect(sessionManager.getEntry(compactionEntry.id)).toEqual(
|
||||
expect.objectContaining({ firstKeptEntryId: branchBUser.id }),
|
||||
);
|
||||
expect(sessionManager.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "compactionSummary", summary: "summary" },
|
||||
{ role: "user", content: "branch b" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "branch b answer" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("does not use session events as append parents", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const userEntry = {
|
||||
type: "message",
|
||||
id: "user-1",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-04T00:00:01.000Z",
|
||||
message: { role: "user", content: "question" },
|
||||
};
|
||||
const sessionEvent = {
|
||||
type: "session",
|
||||
id: "event-1",
|
||||
parentId: userEntry.id,
|
||||
sessionId: "external-session-event",
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[buildSessionHeader(dir, "session-1"), userEntry, sessionEvent]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, dir);
|
||||
const assistantId = sessionManager.appendMessage(buildAssistantMessage("answer"));
|
||||
|
||||
expect(sessionManager.getEntry(assistantId)).toEqual(
|
||||
expect.objectContaining({ parentId: userEntry.id }),
|
||||
);
|
||||
expect(sessionManager.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "answer" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("repairs descendants linked through persisted leaf records", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const rootEntry = {
|
||||
type: "message",
|
||||
id: "root-user",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-04T00:00:01.000Z",
|
||||
message: { role: "user", content: "root question" },
|
||||
};
|
||||
const abandonedEntry = {
|
||||
type: "message",
|
||||
id: "abandoned-assistant",
|
||||
parentId: rootEntry.id,
|
||||
timestamp: "2026-06-04T00:00:02.000Z",
|
||||
message: buildAssistantMessage("abandoned answer"),
|
||||
};
|
||||
const leafEntry = {
|
||||
type: "leaf",
|
||||
id: "leaf-1",
|
||||
parentId: abandonedEntry.id,
|
||||
timestamp: "2026-06-04T00:00:03.000Z",
|
||||
targetId: rootEntry.id,
|
||||
};
|
||||
const replacementEntry = {
|
||||
type: "message",
|
||||
id: "replacement-assistant",
|
||||
parentId: leafEntry.id,
|
||||
timestamp: "2026-06-04T00:00:04.000Z",
|
||||
message: buildAssistantMessage("replacement answer"),
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[buildSessionHeader(dir, "session-1"), rootEntry, abandonedEntry, leafEntry, replacementEntry]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const reopened = SessionManager.open(sessionFile, dir, dir);
|
||||
expect(reopened.getEntry(replacementEntry.id)).toEqual(
|
||||
expect.objectContaining({ parentId: rootEntry.id }),
|
||||
);
|
||||
expect(reopened.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "user", content: "root question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "replacement answer" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("preserves trailing opaque rows when cleanup removes the preceding entry", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionManager = SessionManager.create(dir, dir);
|
||||
sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 });
|
||||
const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer"));
|
||||
const temporaryErrorId = sessionManager.appendMessage(buildAssistantMessage("temporary error"));
|
||||
const opaqueMetadata = { type: "metadata", payload: { source: "plugin" } };
|
||||
const globalMetadata = {
|
||||
type: "custom" as const,
|
||||
id: "plugin-state",
|
||||
parentId: temporaryErrorId,
|
||||
timestamp: "2026-06-04T00:00:04.000Z",
|
||||
customType: "plugin-state",
|
||||
data: { source: "plugin" },
|
||||
};
|
||||
const deliveryEntry = {
|
||||
type: "message" as const,
|
||||
id: "delivery-mirror",
|
||||
parentId: globalMetadata.id,
|
||||
timestamp: "2026-06-04T00:00:05.000Z",
|
||||
message: {
|
||||
...buildAssistantMessage("mirrored delivery"),
|
||||
provider: "openclaw",
|
||||
model: "delivery-mirror",
|
||||
},
|
||||
};
|
||||
sessionManager.mergePromptReleasedSessionEntries([
|
||||
{ type: "prompt_released_opaque", record: opaqueMetadata },
|
||||
globalMetadata,
|
||||
deliveryEntry,
|
||||
]);
|
||||
|
||||
expect(
|
||||
sessionManager.removeTrailingEntries((entry) => entry.id === temporaryErrorId, {
|
||||
preserveTrailing: (entry) =>
|
||||
entry.type === "custom" ||
|
||||
entry.type === "label" ||
|
||||
entry.type === "session_info" ||
|
||||
(entry.type === "message" && isTranscriptOnlyOpenClawAssistantMessage(entry.message)),
|
||||
}),
|
||||
).toBe(1);
|
||||
expect(sessionManager.getLeafId()).toBe(baseAnswerId);
|
||||
const replacementId = sessionManager.appendMessage(buildAssistantMessage("replacement answer"));
|
||||
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
expect(sessionFile).toBeDefined();
|
||||
const records = (await fs.readFile(sessionFile!, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
const metadataIndex = records.findIndex(
|
||||
(record) => JSON.stringify(record) === JSON.stringify(opaqueMetadata),
|
||||
);
|
||||
const globalMetadataIndex = records.findIndex((record) => record.id === globalMetadata.id);
|
||||
const deliveryIndex = records.findIndex((record) => record.id === deliveryEntry.id);
|
||||
const replacementIndex = records.findIndex((record) => record.id === replacementId);
|
||||
expect(metadataIndex).toBeGreaterThan(-1);
|
||||
expect(globalMetadataIndex).toBeGreaterThan(metadataIndex);
|
||||
expect(deliveryIndex).toBeGreaterThan(globalMetadataIndex);
|
||||
expect(replacementIndex).toBeGreaterThan(deliveryIndex);
|
||||
expect(records[globalMetadataIndex]?.parentId).toBe(baseAnswerId);
|
||||
expect(records[deliveryIndex]?.parentId).toBe(globalMetadata.id);
|
||||
expect(SessionManager.open(sessionFile!, dir, dir).buildSessionContext().messages).toHaveLength(
|
||||
3,
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps merged messages downstream of parent-linked opaque events", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionManager = SessionManager.create(dir, dir);
|
||||
sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 });
|
||||
const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer"));
|
||||
const metadata = {
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: baseAnswerId,
|
||||
payload: { source: "plugin" },
|
||||
};
|
||||
const deliveryEntry = {
|
||||
type: "message" as const,
|
||||
id: "plugin-delivery",
|
||||
parentId: baseAnswerId,
|
||||
timestamp: "2026-06-04T00:00:03.000Z",
|
||||
message: buildAssistantMessage("plugin delivery"),
|
||||
};
|
||||
|
||||
sessionManager.mergePromptReleasedSessionEntries([
|
||||
{ type: "prompt_released_opaque", record: metadata },
|
||||
]);
|
||||
sessionManager.mergePromptReleasedSessionEntries([deliveryEntry]);
|
||||
(
|
||||
sessionManager as unknown as {
|
||||
rewriteFile: () => void;
|
||||
}
|
||||
).rewriteFile();
|
||||
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
expect(sessionFile).toBeDefined();
|
||||
const records = (await fs.readFile(sessionFile!, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as {
|
||||
type?: string;
|
||||
id?: string;
|
||||
parentId?: string | null;
|
||||
targetId?: string | null;
|
||||
},
|
||||
);
|
||||
expect(records.find((record) => record.id === deliveryEntry.id)?.parentId).toBe(metadata.id);
|
||||
expect(records.at(-1)).toMatchObject({ type: "leaf", targetId: baseAnswerId });
|
||||
|
||||
const reopened = SessionManager.open(sessionFile!, dir, dir);
|
||||
expect(reopened.getLeafId()).toBe(baseAnswerId);
|
||||
expect(JSON.stringify(reopened.buildSessionContext())).not.toContain("plugin delivery");
|
||||
expect(reopened.getBranch(deliveryEntry.id).map((entry) => entry.id)).toEqual([
|
||||
expect.any(String),
|
||||
baseAnswerId,
|
||||
deliveryEntry.id,
|
||||
]);
|
||||
const branchedFile = reopened.createBranchedSession(deliveryEntry.id);
|
||||
expect(branchedFile).toBeDefined();
|
||||
const branchedRecords = (await fs.readFile(branchedFile!, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { id?: string; parentId?: string | null });
|
||||
expect(branchedRecords).toContainEqual(metadata);
|
||||
expect(branchedRecords.find((record) => record.id === deliveryEntry.id)?.parentId).toBe(
|
||||
metadata.id,
|
||||
);
|
||||
});
|
||||
|
||||
it("persists the active leaf immediately after merging prompt-released side rows", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionManager = SessionManager.create(dir, dir);
|
||||
sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 });
|
||||
const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer"));
|
||||
const sideEntry = {
|
||||
type: "message" as const,
|
||||
id: "side-delivery",
|
||||
parentId: baseAnswerId,
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
message: buildAssistantMessage("side delivery"),
|
||||
};
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
expect(sessionFile).toBeDefined();
|
||||
await fs.appendFile(sessionFile!, `${JSON.stringify(sideEntry)}\n`, "utf8");
|
||||
|
||||
const mergeResult = sessionManager.mergePromptReleasedSessionEntries([sideEntry], {
|
||||
persistLeaf: true,
|
||||
});
|
||||
|
||||
expect(mergeResult?.publishedEntries).toEqual([{ kind: "id", id: expect.any(String) }]);
|
||||
const records = (await fs.readFile(sessionFile!, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as {
|
||||
type?: string;
|
||||
id?: string;
|
||||
parentId?: string | null;
|
||||
targetId?: string | null;
|
||||
appendParentId?: string | null;
|
||||
appendMode?: string;
|
||||
},
|
||||
);
|
||||
expect(records.at(-1)).toMatchObject({
|
||||
type: "leaf",
|
||||
parentId: sideEntry.id,
|
||||
targetId: baseAnswerId,
|
||||
appendParentId: sideEntry.id,
|
||||
appendMode: "side",
|
||||
});
|
||||
|
||||
const nextSideEntry = {
|
||||
...sideEntry,
|
||||
id: "next-side-delivery",
|
||||
parentId: records.at(-1)?.appendParentId ?? records.at(-1)?.targetId ?? null,
|
||||
appendMode: "side" as const,
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
message: buildAssistantMessage("next side delivery"),
|
||||
};
|
||||
const reopenedForNextMerge = SessionManager.open(sessionFile!, dir, dir);
|
||||
await fs.appendFile(sessionFile!, `${JSON.stringify(nextSideEntry)}\n`, "utf8");
|
||||
reopenedForNextMerge.mergePromptReleasedSessionEntries([nextSideEntry], {
|
||||
persistLeaf: true,
|
||||
});
|
||||
|
||||
const finalRecords = (await fs.readFile(sessionFile!, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as {
|
||||
type?: string;
|
||||
id?: string;
|
||||
parentId?: string | null;
|
||||
targetId?: string | null;
|
||||
appendParentId?: string | null;
|
||||
appendMode?: string;
|
||||
},
|
||||
);
|
||||
expect(finalRecords.find((record) => record.id === nextSideEntry.id)?.parentId).toBe(
|
||||
sideEntry.id,
|
||||
);
|
||||
expect(finalRecords.at(-1)).toMatchObject({
|
||||
type: "message",
|
||||
id: nextSideEntry.id,
|
||||
parentId: sideEntry.id,
|
||||
appendMode: "side",
|
||||
});
|
||||
|
||||
const reopened = SessionManager.open(sessionFile!, dir, dir);
|
||||
expect(reopened.getLeafId()).toBe(baseAnswerId);
|
||||
expect(JSON.stringify(reopened.buildSessionContext())).not.toContain("side delivery");
|
||||
expect(
|
||||
reopened
|
||||
.getBranch(nextSideEntry.id)
|
||||
.map((entry) => entry.id)
|
||||
.slice(-2),
|
||||
).toEqual([sideEntry.id, nextSideEntry.id]);
|
||||
|
||||
const nextUserId = reopened.appendMessage({
|
||||
role: "user",
|
||||
content: "next question",
|
||||
timestamp: 3,
|
||||
});
|
||||
expect(
|
||||
reopened
|
||||
.getBranch(nextUserId)
|
||||
.map((entry) => entry.id)
|
||||
.slice(-2),
|
||||
).toEqual([baseAnswerId, nextUserId]);
|
||||
expect(JSON.stringify(reopened.buildSessionContext())).not.toContain("side delivery");
|
||||
});
|
||||
|
||||
it("applies merged leaf controls across separate callbacks", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionManager = SessionManager.create(dir, dir);
|
||||
sessionManager.appendMessage({ role: "user", content: "question", timestamp: 1 });
|
||||
const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer"));
|
||||
const metadata = {
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: baseAnswerId,
|
||||
payload: { source: "plugin" },
|
||||
};
|
||||
const leafEntry = {
|
||||
type: "leaf",
|
||||
id: "plugin-leaf",
|
||||
parentId: metadata.id,
|
||||
timestamp: "2026-06-04T00:00:03.000Z",
|
||||
targetId: baseAnswerId,
|
||||
};
|
||||
const deliveryEntry = {
|
||||
type: "message" as const,
|
||||
id: "plugin-delivery",
|
||||
parentId: leafEntry.id,
|
||||
timestamp: "2026-06-04T00:00:04.000Z",
|
||||
message: buildAssistantMessage("plugin delivery"),
|
||||
};
|
||||
|
||||
sessionManager.mergePromptReleasedSessionEntries([
|
||||
{ type: "prompt_released_opaque", record: metadata },
|
||||
]);
|
||||
sessionManager.mergePromptReleasedSessionEntries([
|
||||
{ type: "prompt_released_opaque", record: leafEntry },
|
||||
]);
|
||||
sessionManager.mergePromptReleasedSessionEntries([deliveryEntry]);
|
||||
(
|
||||
sessionManager as unknown as {
|
||||
rewriteFile: () => void;
|
||||
}
|
||||
).rewriteFile();
|
||||
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
expect(sessionFile).toBeDefined();
|
||||
const records = (await fs.readFile(sessionFile!, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map(
|
||||
(line) =>
|
||||
JSON.parse(line) as {
|
||||
type?: string;
|
||||
id?: string;
|
||||
parentId?: string | null;
|
||||
targetId?: string | null;
|
||||
},
|
||||
);
|
||||
expect(records.find((record) => record.id === deliveryEntry.id)?.parentId).toBe(baseAnswerId);
|
||||
expect(records.at(-1)).toMatchObject({ type: "leaf", targetId: baseAnswerId });
|
||||
const reopened = SessionManager.open(sessionFile!, dir, dir);
|
||||
expect(reopened.getLeafId()).toBe(baseAnswerId);
|
||||
expect(JSON.stringify(reopened.buildSessionContext())).not.toContain("plugin delivery");
|
||||
expect(reopened.getBranch(deliveryEntry.id).map((entry) => entry.id)).toEqual([
|
||||
expect.any(String),
|
||||
baseAnswerId,
|
||||
deliveryEntry.id,
|
||||
]);
|
||||
});
|
||||
|
||||
it("round-trips a visible leaf with a distinct opaque append parent", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const baseAnswer = {
|
||||
type: "message",
|
||||
id: "base-answer",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: buildAssistantMessage("base answer"),
|
||||
};
|
||||
const metadata = {
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: null,
|
||||
payload: { source: "plugin" },
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[buildSessionHeader(dir, "session-1"), baseAnswer, metadata]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, dir);
|
||||
sessionManager.mergePromptReleasedSessionEntries([
|
||||
{
|
||||
type: "message",
|
||||
id: "side-delivery",
|
||||
parentId: baseAnswer.id,
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
message: buildAssistantMessage("side delivery"),
|
||||
},
|
||||
]);
|
||||
(
|
||||
sessionManager as unknown as {
|
||||
rewriteFile: () => void;
|
||||
}
|
||||
).rewriteFile();
|
||||
|
||||
const rewritten = (await fs.readFile(sessionFile, "utf-8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
expect(rewritten.at(-1)).toMatchObject({
|
||||
type: "leaf",
|
||||
targetId: baseAnswer.id,
|
||||
appendParentId: metadata.id,
|
||||
});
|
||||
|
||||
const reopened = SessionManager.open(sessionFile, dir, dir);
|
||||
expect(reopened.getLeafId()).toBe(baseAnswer.id);
|
||||
const nextId = reopened.appendMessage(buildAssistantMessage("active continuation"));
|
||||
const records = (await fs.readFile(sessionFile, "utf-8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { id?: string; parentId?: string | null });
|
||||
expect(records.find((entry) => entry.id === nextId)?.parentId).toBe(metadata.id);
|
||||
expect(reopened.getBranch(nextId).map((entry) => entry.id)).toEqual([baseAnswer.id, nextId]);
|
||||
const branchedFile = reopened.createBranchedSession(nextId);
|
||||
expect(branchedFile).toBeDefined();
|
||||
const branchedRecords = (await fs.readFile(branchedFile!, "utf-8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { id?: string; parentId?: string | null });
|
||||
expect(branchedRecords.find((entry) => entry.id === metadata.id)).toMatchObject({
|
||||
parentId: baseAnswer.id,
|
||||
});
|
||||
expect(branchedRecords.find((entry) => entry.id === nextId)).toMatchObject({
|
||||
parentId: metadata.id,
|
||||
});
|
||||
});
|
||||
|
||||
it("reopens parentless canonical rows as one visible branch", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
buildSessionHeader(dir, "session-1"),
|
||||
{
|
||||
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: buildAssistantMessage("answer"),
|
||||
},
|
||||
{
|
||||
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 reopened = SessionManager.open(sessionFile, dir, dir);
|
||||
|
||||
expect(reopened.getBranch().map((entry) => entry.id)).toEqual(["user-1", "assistant-1"]);
|
||||
expect(reopened.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "user", content: "question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "answer" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores persisted leaf controls with dangling references", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
buildSessionHeader(dir, "session-1"),
|
||||
{
|
||||
type: "message",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: buildAssistantMessage("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",
|
||||
"utf-8",
|
||||
);
|
||||
|
||||
const reopened = SessionManager.open(sessionFile, dir, dir);
|
||||
expect(reopened.getLeafId()).toBe("active-root");
|
||||
const nextId = reopened.appendMessage(buildAssistantMessage("continued"));
|
||||
const records = (await fs.readFile(sessionFile, "utf-8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { id?: string; parentId?: string | null });
|
||||
expect(records.find((entry) => entry.id === nextId)?.parentId).toBe("plugin-metadata");
|
||||
expect(reopened.buildSessionContext().messages).toMatchObject([
|
||||
{ role: "assistant", content: [{ type: "text", text: "active" }] },
|
||||
{ role: "assistant", content: [{ type: "text", text: "continued" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("ignores dangling leaf controls merged while a prompt is released", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionManager = SessionManager.create(dir, dir);
|
||||
const baseAnswerId = sessionManager.appendMessage(buildAssistantMessage("base answer"));
|
||||
const metadata = {
|
||||
type: "metadata",
|
||||
id: "plugin-metadata",
|
||||
parentId: baseAnswerId,
|
||||
payload: { source: "plugin" },
|
||||
};
|
||||
sessionManager.mergePromptReleasedSessionEntries([
|
||||
{ type: "prompt_released_opaque", record: metadata },
|
||||
]);
|
||||
sessionManager.mergePromptReleasedSessionEntries([
|
||||
{
|
||||
type: "prompt_released_opaque",
|
||||
record: {
|
||||
type: "leaf",
|
||||
id: "missing-target",
|
||||
parentId: metadata.id,
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
targetId: "missing",
|
||||
},
|
||||
},
|
||||
]);
|
||||
sessionManager.mergePromptReleasedSessionEntries([
|
||||
{
|
||||
type: "prompt_released_opaque",
|
||||
record: {
|
||||
type: "leaf",
|
||||
id: "missing-append",
|
||||
parentId: "missing-target",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: baseAnswerId,
|
||||
appendParentId: "missing",
|
||||
},
|
||||
},
|
||||
]);
|
||||
sessionManager.mergePromptReleasedSessionEntries([
|
||||
{
|
||||
type: "message",
|
||||
id: "side-delivery",
|
||||
parentId: baseAnswerId,
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
message: buildAssistantMessage("side delivery"),
|
||||
},
|
||||
]);
|
||||
(
|
||||
sessionManager as unknown as {
|
||||
rewriteFile: () => void;
|
||||
}
|
||||
).rewriteFile();
|
||||
|
||||
expect(sessionManager.getLeafId()).toBe(baseAnswerId);
|
||||
const sessionFile = sessionManager.getSessionFile();
|
||||
expect(sessionFile).toBeDefined();
|
||||
const records = (await fs.readFile(sessionFile!, "utf-8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as { id?: string; parentId?: string | null });
|
||||
expect(records.find((entry) => entry.id === "side-delivery")?.parentId).toBe(metadata.id);
|
||||
});
|
||||
|
||||
it("removes leaf controls that target regenerated labels when branching", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
const rootEntry = {
|
||||
type: "message",
|
||||
id: "root-user",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-04T00:00:01.000Z",
|
||||
message: { role: "user", content: "root question" },
|
||||
};
|
||||
const labelEntry = {
|
||||
type: "label",
|
||||
id: "label-1",
|
||||
parentId: rootEntry.id,
|
||||
timestamp: "2026-06-04T00:00:02.000Z",
|
||||
targetId: rootEntry.id,
|
||||
label: "selected",
|
||||
};
|
||||
const abandonedEntry = {
|
||||
type: "message",
|
||||
id: "abandoned-assistant",
|
||||
parentId: labelEntry.id,
|
||||
timestamp: "2026-06-04T00:00:03.000Z",
|
||||
message: buildAssistantMessage("abandoned answer"),
|
||||
};
|
||||
const leafEntry = {
|
||||
type: "leaf",
|
||||
id: "leaf-1",
|
||||
parentId: abandonedEntry.id,
|
||||
timestamp: "2026-06-04T00:00:04.000Z",
|
||||
targetId: labelEntry.id,
|
||||
};
|
||||
const replacementEntry = {
|
||||
type: "message",
|
||||
id: "replacement-assistant",
|
||||
parentId: leafEntry.id,
|
||||
timestamp: "2026-06-04T00:00:05.000Z",
|
||||
message: buildAssistantMessage("replacement answer"),
|
||||
};
|
||||
await fs.writeFile(
|
||||
sessionFile,
|
||||
[
|
||||
buildSessionHeader(dir, "session-1"),
|
||||
rootEntry,
|
||||
labelEntry,
|
||||
abandonedEntry,
|
||||
leafEntry,
|
||||
replacementEntry,
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
"utf8",
|
||||
);
|
||||
|
||||
const sessionManager = SessionManager.open(sessionFile, dir, dir);
|
||||
const branchedFile = sessionManager.createBranchedSession(replacementEntry.id);
|
||||
expect(branchedFile).toBeDefined();
|
||||
const branchedRecords = (await fs.readFile(branchedFile!, "utf8"))
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as Record<string, unknown>);
|
||||
|
||||
expect(branchedRecords.some((record) => record.type === "leaf")).toBe(false);
|
||||
expect(branchedRecords.find((record) => record.id === replacementEntry.id)?.parentId).toBe(
|
||||
rootEntry.id,
|
||||
);
|
||||
expect(branchedRecords).toContainEqual(
|
||||
expect.objectContaining({
|
||||
type: "label",
|
||||
targetId: rootEntry.id,
|
||||
label: labelEntry.label,
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
SessionManager.open(branchedFile!, dir, dir).buildSessionContext().messages,
|
||||
).toMatchObject([
|
||||
{ role: "user", content: "root question" },
|
||||
{ role: "assistant", content: [{ type: "text", text: "replacement answer" }] },
|
||||
]);
|
||||
});
|
||||
|
||||
it("keeps the warm cache after prepareSessionManagerForRun rewrites then appends", async () => {
|
||||
const dir = await makeTempDir();
|
||||
const sessionFile = path.join(dir, "session.jsonl");
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,4 +1,4 @@
|
||||
import { mkdtempSync, writeFileSync } from "node:fs";
|
||||
import { mkdtempSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
@@ -68,4 +68,62 @@ describe("v1 session migration id assignment", () => {
|
||||
expect(messages[1].parentId).toBe(messages[0].id);
|
||||
expect(messages[1].parentId).not.toBe(messages[1].id);
|
||||
});
|
||||
|
||||
it("preserves compaction indexes across opaque rows", () => {
|
||||
const dir = mkdtempSync(join(tmpdir(), "oc-v1mig-compaction-"));
|
||||
const file = join(dir, "session.jsonl");
|
||||
const keptMessage = {
|
||||
type: "message",
|
||||
timestamp: "2026-01-01T00:00:02.000Z",
|
||||
message: { role: "user", content: "kept" },
|
||||
};
|
||||
writeFileSync(
|
||||
file,
|
||||
[
|
||||
{
|
||||
type: "session",
|
||||
version: 1,
|
||||
id: "v1-header-id",
|
||||
timestamp: "2026-01-01T00:00:00.000Z",
|
||||
cwd: "/tmp/cwd",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
timestamp: "2026-01-01T00:00:01.000Z",
|
||||
message: { role: "user", content: "prelude" },
|
||||
},
|
||||
null,
|
||||
keptMessage,
|
||||
{
|
||||
type: "compaction",
|
||||
timestamp: "2026-01-01T00:00:03.000Z",
|
||||
summary: "summary",
|
||||
firstKeptEntryIndex: 3,
|
||||
tokensBefore: 200,
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n") + "\n",
|
||||
);
|
||||
|
||||
const sm = SessionManager.open(file, dir);
|
||||
const kept = sm
|
||||
.getEntries()
|
||||
.find(
|
||||
(entry) =>
|
||||
entry.type === "message" &&
|
||||
entry.message.role === "user" &&
|
||||
entry.message.content === "kept",
|
||||
);
|
||||
const compaction = sm.getEntries().find((entry) => entry.type === "compaction");
|
||||
|
||||
expect(kept).toBeDefined();
|
||||
expect(compaction).toMatchObject({ firstKeptEntryId: kept?.id });
|
||||
expect(
|
||||
readFileSync(file, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line) as unknown),
|
||||
).toContain(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -830,6 +830,7 @@ CRITICAL CONSTRAINTS:
|
||||
- sessionTarget="main" REQUIRES payload.kind="systemEvent"
|
||||
- sessionTarget="isolated" | "current" | "session:xxx" REQUIRES payload.kind="agentTurn"
|
||||
- Webhook: delivery.mode="webhook" and delivery.to URL.
|
||||
- Operators may set a minimum interval; add/update is rejected if a recurring every/cron schedule fires too frequently. On that error, increase the interval rather than retrying.
|
||||
Default: prefer isolated agentTurn jobs unless the user explicitly wants current-session binding.
|
||||
|
||||
RESTRICTED CRON RUNS:
|
||||
|
||||
@@ -7,6 +7,8 @@ import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-erro
|
||||
import { MissingProviderAuthError } from "../../agents/model-auth.js";
|
||||
import type { SessionEntry } from "../../config/sessions.js";
|
||||
import type { ModelDefinitionConfig } from "../../config/types.models.js";
|
||||
import { resetLogger, setLoggerOverride } from "../../logging/logger.js";
|
||||
import { loggingState } from "../../logging/state.js";
|
||||
import { CommandLaneClearedError, GatewayDrainingError } from "../../process/command-queue.js";
|
||||
import {
|
||||
createUserTurnTranscriptRecorder,
|
||||
@@ -5142,6 +5144,69 @@ describe("runAgentTurnWithFallback", () => {
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("logs Codex app-server compaction completion while notices stay silent by default", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
const consoleLog = vi.fn();
|
||||
setLoggerOverride({ level: "silent", consoleLevel: "info", consoleStyle: "compact" });
|
||||
loggingState.rawConsole = {
|
||||
log: consoleLog,
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
try {
|
||||
state.runWithModelFallbackMock.mockImplementationOnce(
|
||||
async (params: FallbackRunnerParams) => ({
|
||||
result: await params.run("openai", "gpt-5.5"),
|
||||
provider: "openai",
|
||||
model: "gpt-5.5",
|
||||
attempts: [{ provider: "anthropic", model: "claude", error: "rate limit" }],
|
||||
}),
|
||||
);
|
||||
state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
|
||||
await params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: {
|
||||
phase: "start",
|
||||
backend: "codex-app-server",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "compaction-1",
|
||||
},
|
||||
});
|
||||
await params.onAgentEvent?.({
|
||||
stream: "compaction",
|
||||
data: {
|
||||
phase: "end",
|
||||
completed: true,
|
||||
backend: "codex-app-server",
|
||||
threadId: "thread-1",
|
||||
turnId: "turn-1",
|
||||
itemId: "compaction-1",
|
||||
},
|
||||
});
|
||||
return { payloads: [{ text: "final" }], meta: {} };
|
||||
});
|
||||
|
||||
const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
|
||||
const result = await runAgentTurnWithFallback({
|
||||
...createMinimalRunAgentTurnParams({
|
||||
opts: { onBlockReply },
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.kind).toBe("success");
|
||||
expect(onBlockReply).not.toHaveBeenCalled();
|
||||
expect(consoleLog.mock.calls.map(([line]) => String(line)).join("\n")).toContain(
|
||||
"codex app-server auto-compaction succeeded for openai/gpt-5.5; refreshed session context",
|
||||
);
|
||||
} finally {
|
||||
loggingState.rawConsole = null;
|
||||
setLoggerOverride(null);
|
||||
resetLogger();
|
||||
}
|
||||
});
|
||||
|
||||
it("emits a compaction start notice when notifyUser is enabled", async () => {
|
||||
const onBlockReply = vi.fn();
|
||||
state.runEmbeddedAgentMock.mockImplementationOnce(async (params: EmbeddedAgentParams) => {
|
||||
|
||||
@@ -163,9 +163,26 @@ type AgentTurnTimingSummary = {
|
||||
};
|
||||
|
||||
const agentTurnTimingLog = createSubsystemLogger("auto-reply/agent-turn-timing");
|
||||
const agentCompactionLog = createSubsystemLogger("auto-reply/compaction");
|
||||
const CODEX_APP_SERVER_COMPACTION_BACKEND = "codex-app-server";
|
||||
const AGENT_TURN_TIMING_WARN_TOTAL_MS = 1_000;
|
||||
const AGENT_TURN_TIMING_WARN_STAGE_MS = 500;
|
||||
|
||||
function formatCompactionModelRef(provider?: string, model?: string): string {
|
||||
const normalizedProvider = normalizeOptionalString(provider);
|
||||
const normalizedModel = normalizeOptionalString(model);
|
||||
if (normalizedProvider && normalizedModel) {
|
||||
return `${sanitizeForLog(normalizedProvider)}/${sanitizeForLog(normalizedModel)}`;
|
||||
}
|
||||
if (normalizedProvider) {
|
||||
return sanitizeForLog(normalizedProvider);
|
||||
}
|
||||
if (normalizedModel) {
|
||||
return sanitizeForLog(normalizedModel);
|
||||
}
|
||||
return "unknown model";
|
||||
}
|
||||
|
||||
function createAgentTurnTimingTracker(options: { profilerEnabled?: boolean } = {}): {
|
||||
measure: <T>(name: string, run: () => Promise<T> | T) => Promise<T>;
|
||||
measureSync: <T>(name: string, run: () => T) => T;
|
||||
@@ -2710,6 +2727,7 @@ export async function runAgentTurnWithFallback(params: {
|
||||
}
|
||||
if (evt.stream === "compaction") {
|
||||
const phase = readStringValue(evt.data.phase) ?? "";
|
||||
const backend = readStringValue(evt.data.backend);
|
||||
const hookMessages = readCompactionHookMessages(evt.data.messages);
|
||||
const sendCompactionUserNotices = async (
|
||||
noticePhase: "start" | "end" | "incomplete",
|
||||
@@ -2731,6 +2749,28 @@ export async function runAgentTurnWithFallback(params: {
|
||||
const completed = evt.data?.completed === true;
|
||||
if (completed) {
|
||||
attemptCompactionCount += 1;
|
||||
if (backend === CODEX_APP_SERVER_COMPACTION_BACKEND) {
|
||||
const modelRef = formatCompactionModelRef(provider, model);
|
||||
const consoleMessage =
|
||||
`codex app-server auto-compaction succeeded for ${modelRef}; ` +
|
||||
"refreshed session context";
|
||||
agentCompactionLog.info(
|
||||
"codex app-server auto-compaction succeeded",
|
||||
{
|
||||
event: "codex_app_server_compaction_succeeded",
|
||||
backend,
|
||||
provider,
|
||||
model,
|
||||
sessionKey: params.sessionKey,
|
||||
sessionId: effectiveRun.sessionId,
|
||||
threadId: readStringValue(evt.data.threadId),
|
||||
turnId: readStringValue(evt.data.turnId),
|
||||
itemId: readStringValue(evt.data.itemId),
|
||||
compactionCount: attemptCompactionCount,
|
||||
consoleMessage,
|
||||
},
|
||||
);
|
||||
}
|
||||
if (params.opts?.onCompactionEnd) {
|
||||
await params.opts.onCompactionEnd();
|
||||
}
|
||||
|
||||
@@ -153,4 +153,69 @@ describe("emitResetCommandHooks", () => {
|
||||
expect(event.reason).toBe("new");
|
||||
expect(ctx.sessionId).toBe("prev-session");
|
||||
});
|
||||
|
||||
it("keeps leaf-controlled side branches out of before_reset hooks", async () => {
|
||||
fsMocks.readFile.mockResolvedValueOnce(
|
||||
[
|
||||
{
|
||||
type: "message",
|
||||
id: "active-root",
|
||||
parentId: null,
|
||||
message: { role: "user", content: "active root" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "side-entry",
|
||||
parentId: "active-root",
|
||||
message: { role: "assistant", content: "side delivery" },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "side-entry",
|
||||
targetId: "active-root",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "active-tail",
|
||||
parentId: "active-root",
|
||||
message: { role: "assistant", content: "active tail" },
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "opaque-after-active-tail",
|
||||
parentId: "side-entry",
|
||||
},
|
||||
]
|
||||
.map((entry) => JSON.stringify(entry))
|
||||
.join("\n"),
|
||||
);
|
||||
|
||||
await emitResetCommandHooks({
|
||||
action: "new",
|
||||
ctx: {} as HandleCommandsParams["ctx"],
|
||||
cfg: {} as HandleCommandsParams["cfg"],
|
||||
command: {
|
||||
surface: "discord",
|
||||
senderId: "rai",
|
||||
channel: "discord",
|
||||
from: "discord:rai",
|
||||
to: "discord:bot",
|
||||
resetHookTriggered: false,
|
||||
} as HandleCommandsParams["command"],
|
||||
sessionKey: "agent:main:main",
|
||||
previousSessionEntry: {
|
||||
sessionId: "prev-session",
|
||||
sessionFile: "/tmp/prev-session.jsonl",
|
||||
} as HandleCommandsParams["previousSessionEntry"],
|
||||
workspaceDir: "/tmp/openclaw-workspace",
|
||||
});
|
||||
|
||||
await vi.waitFor(() => expect(hookRunnerMocks.runBeforeReset).toHaveBeenCalledTimes(1));
|
||||
const [event] = firstBeforeResetCall();
|
||||
expect(event.messages).toEqual([
|
||||
{ role: "user", content: "active root" },
|
||||
{ role: "assistant", content: "active tail" },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -265,6 +265,7 @@ describe("buildExportSessionReply", () => {
|
||||
header: null,
|
||||
entries: [],
|
||||
leafId: null,
|
||||
hasLeafControl: false,
|
||||
systemPrompt: "system prompt",
|
||||
tools: [],
|
||||
}),
|
||||
@@ -273,6 +274,194 @@ describe("buildExportSessionReply", () => {
|
||||
expect(html).toContain('const base64 = document.getElementById("session-data").textContent;');
|
||||
});
|
||||
|
||||
it("exports the active target selected by a terminal leaf control", async () => {
|
||||
const entries = [
|
||||
{
|
||||
type: "message",
|
||||
id: "active-tail",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "assistant", content: "active" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "inactive-tail",
|
||||
parentId: "active-tail",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
message: { role: "assistant", content: "side delivery" },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive-tail",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-tail",
|
||||
},
|
||||
];
|
||||
hoisted.sessionTranscriptContent = entries.map((entry) => JSON.stringify(entry)).join("\n");
|
||||
|
||||
await buildExportSessionReply(makeParams());
|
||||
|
||||
expect(writtenHtml()).toContain(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
header: null,
|
||||
entries: [entries[0], entries[1], { ...entries[2], parentId: "active-tail" }],
|
||||
leafId: "active-tail",
|
||||
hasLeafControl: true,
|
||||
systemPrompt: "system prompt",
|
||||
tools: [],
|
||||
}),
|
||||
).toString("base64"),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes a leaf control parent before exporting its active descendant", async () => {
|
||||
const rawEntries = [
|
||||
{
|
||||
type: "message",
|
||||
id: "active-tail",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "assistant", content: "active" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "inactive-tail",
|
||||
parentId: "active-tail",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
message: { role: "assistant", content: "side delivery" },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive-tail",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
targetId: "active-tail",
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "replacement",
|
||||
parentId: "active-leaf",
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
message: { role: "assistant", content: "replacement" },
|
||||
},
|
||||
];
|
||||
hoisted.sessionTranscriptContent = rawEntries.map((entry) => JSON.stringify(entry)).join("\n");
|
||||
|
||||
await buildExportSessionReply(makeParams());
|
||||
|
||||
expect(writtenHtml()).toContain(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
header: null,
|
||||
entries: [
|
||||
rawEntries[0],
|
||||
rawEntries[1],
|
||||
{ ...rawEntries[2], parentId: "active-tail" },
|
||||
{ ...rawEntries[3], parentId: "active-tail" },
|
||||
],
|
||||
leafId: "replacement",
|
||||
hasLeafControl: true,
|
||||
systemPrompt: "system prompt",
|
||||
tools: [],
|
||||
}),
|
||||
).toString("base64"),
|
||||
);
|
||||
});
|
||||
|
||||
it("normalizes parentless history addressed by a leaf control", async () => {
|
||||
const rawEntries = [
|
||||
{
|
||||
type: "message",
|
||||
id: "active-root",
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "user", content: "root" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "active-tail",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
message: { role: "assistant", content: "active" },
|
||||
},
|
||||
{
|
||||
type: "message",
|
||||
id: "inactive-tail",
|
||||
parentId: "active-tail",
|
||||
timestamp: "2026-06-15T00:00:03.000Z",
|
||||
message: { role: "assistant", content: "side delivery" },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "active-leaf",
|
||||
parentId: "inactive-tail",
|
||||
timestamp: "2026-06-15T00:00:04.000Z",
|
||||
targetId: "active-tail",
|
||||
},
|
||||
];
|
||||
hoisted.sessionTranscriptContent = rawEntries.map((entry) => JSON.stringify(entry)).join("\n");
|
||||
|
||||
await buildExportSessionReply(makeParams());
|
||||
|
||||
expect(writtenHtml()).toContain(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
header: null,
|
||||
entries: [
|
||||
{ ...rawEntries[0], parentId: null },
|
||||
{ ...rawEntries[1], parentId: "active-root" },
|
||||
rawEntries[2],
|
||||
{ ...rawEntries[3], parentId: "active-tail" },
|
||||
],
|
||||
leafId: "active-tail",
|
||||
hasLeafControl: true,
|
||||
systemPrompt: "system prompt",
|
||||
tools: [],
|
||||
}),
|
||||
).toString("base64"),
|
||||
);
|
||||
});
|
||||
|
||||
it("preserves an explicitly empty branch selected by a terminal leaf control", async () => {
|
||||
const entries = [
|
||||
{
|
||||
type: "message",
|
||||
id: "inactive-tail",
|
||||
parentId: null,
|
||||
timestamp: "2026-06-15T00:00:01.000Z",
|
||||
message: { role: "assistant", content: "inactive" },
|
||||
},
|
||||
{
|
||||
type: "leaf",
|
||||
id: "empty-leaf",
|
||||
parentId: "inactive-tail",
|
||||
timestamp: "2026-06-15T00:00:02.000Z",
|
||||
targetId: null,
|
||||
},
|
||||
{
|
||||
type: "metadata",
|
||||
id: "opaque-after-leaf",
|
||||
parentId: "inactive-tail",
|
||||
},
|
||||
];
|
||||
hoisted.sessionTranscriptContent = entries.map((entry) => JSON.stringify(entry)).join("\n");
|
||||
|
||||
await buildExportSessionReply(makeParams());
|
||||
|
||||
expect(writtenHtml()).toContain(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
header: null,
|
||||
entries: [entries[0], { ...entries[1], parentId: null }, entries[2]],
|
||||
leafId: null,
|
||||
hasLeafControl: true,
|
||||
systemPrompt: "system prompt",
|
||||
tools: [],
|
||||
}),
|
||||
).toString("base64"),
|
||||
);
|
||||
});
|
||||
|
||||
it("suffixes colliding default export filenames instead of overwriting", async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2026-05-05T10:11:12.345Z"));
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
type SessionEntry as AgentSessionEntry,
|
||||
type SessionHeader,
|
||||
} from "../../agents/sessions/session-manager.js";
|
||||
import { scanSessionTranscriptTree } from "../../config/sessions/transcript-tree.js";
|
||||
import { pathExists } from "../../infra/fs-safe.js";
|
||||
import type { ReplyPayload } from "../types.js";
|
||||
import {
|
||||
@@ -26,6 +27,7 @@ interface SessionData {
|
||||
header: SessionHeader | null;
|
||||
entries: AgentSessionEntry[];
|
||||
leafId: string | null;
|
||||
hasLeafControl: boolean;
|
||||
systemPrompt?: string;
|
||||
tools?: Array<{ name: string; description?: string; parameters?: unknown }>;
|
||||
}
|
||||
@@ -240,6 +242,7 @@ async function readSessionDataFromTranscript(sessionFile: string): Promise<{
|
||||
header: SessionHeader | null;
|
||||
entries: AgentSessionEntry[];
|
||||
leafId: string | null;
|
||||
hasLeafControl: boolean;
|
||||
warnings: SessionExportWarningSummary[];
|
||||
}> {
|
||||
const raw = await fsp.readFile(sessionFile, "utf-8");
|
||||
@@ -247,12 +250,26 @@ async function readSessionDataFromTranscript(sessionFile: string): Promise<{
|
||||
migrateSessionEntries(fileEntries);
|
||||
const header =
|
||||
fileEntries.find((entry): entry is SessionHeader => entry.type === "session") ?? null;
|
||||
const entries = fileEntries.filter(
|
||||
const rawEntries = fileEntries.filter(
|
||||
(entry): entry is AgentSessionEntry => entry.type !== "session",
|
||||
);
|
||||
const lastEntry = entries.at(-1);
|
||||
const leafId = typeof lastEntry?.id === "string" ? lastEntry.id : null;
|
||||
return { header, entries, leafId, warnings: summarizeSessionExportWarnings(warnings) };
|
||||
const tree = scanSessionTranscriptTree(rawEntries);
|
||||
const hasLeafControl = tree.hasLeafControl;
|
||||
const entries = hasLeafControl
|
||||
? rawEntries.map((entry) => {
|
||||
const node = tree.byId.get(entry.id);
|
||||
return node && entry.parentId !== node.parentId
|
||||
? ({ ...entry, parentId: node.parentId } as AgentSessionEntry)
|
||||
: entry;
|
||||
})
|
||||
: rawEntries;
|
||||
return {
|
||||
header,
|
||||
entries,
|
||||
leafId: tree.leafId,
|
||||
hasLeafControl,
|
||||
warnings: summarizeSessionExportWarnings(warnings),
|
||||
};
|
||||
}
|
||||
|
||||
export async function buildExportSessionReply(params: HandleCommandsParams): Promise<ReplyPayload> {
|
||||
@@ -274,7 +291,8 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro
|
||||
}
|
||||
|
||||
// 2. Load session entries
|
||||
const { entries, header, leafId, warnings } = await readSessionDataFromTranscript(sessionFile);
|
||||
const { entries, header, leafId, hasLeafControl, warnings } =
|
||||
await readSessionDataFromTranscript(sessionFile);
|
||||
|
||||
// 3. Build full system prompt
|
||||
const { systemPrompt, tools } = await resolveCommandsSystemPromptBundle({
|
||||
@@ -287,6 +305,7 @@ export async function buildExportSessionReply(params: HandleCommandsParams): Pro
|
||||
header,
|
||||
entries,
|
||||
leafId,
|
||||
hasLeafControl,
|
||||
systemPrompt,
|
||||
tools: tools.map((t) => ({
|
||||
name: t.name,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
// Emits reset hooks and cleanup work around session reset commands.
|
||||
import fs from "node:fs/promises";
|
||||
import path from "node:path";
|
||||
import { selectSessionTranscriptLeafControlledPath } from "../../config/sessions/transcript-tree.js";
|
||||
import { logVerbose } from "../../globals.js";
|
||||
import { createInternalHookEvent, triggerInternalHook } from "../../hooks/internal-hooks.js";
|
||||
import { getGlobalHookRunner } from "../../plugins/hook-runner-global.js";
|
||||
@@ -17,21 +18,31 @@ function loadRouteReplyRuntime() {
|
||||
export type ResetCommandAction = "new" | "reset";
|
||||
|
||||
function parseTranscriptMessages(content: string): unknown[] {
|
||||
const messages: unknown[] = [];
|
||||
const entries: unknown[] = [];
|
||||
for (const line of content.split("\n")) {
|
||||
if (!line.trim()) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const entry = JSON.parse(line);
|
||||
if (entry.type === "message" && entry.message) {
|
||||
messages.push(entry.message);
|
||||
}
|
||||
entries.push(entry);
|
||||
} catch {
|
||||
// Skip malformed lines from partially-written transcripts.
|
||||
}
|
||||
}
|
||||
return messages;
|
||||
const selectedEntries = selectSessionTranscriptLeafControlledPath(entries) ?? entries;
|
||||
return selectedEntries.flatMap((entry) => {
|
||||
if (
|
||||
entry &&
|
||||
typeof entry === "object" &&
|
||||
!Array.isArray(entry) &&
|
||||
(entry as { type?: unknown }).type === "message" &&
|
||||
(entry as { message?: unknown }).message
|
||||
) {
|
||||
return [(entry as { message: unknown }).message];
|
||||
}
|
||||
return [];
|
||||
});
|
||||
}
|
||||
|
||||
async function findLatestArchivedTranscript(sessionFile: string): Promise<string | undefined> {
|
||||
|
||||
@@ -480,6 +480,16 @@ vi.mock("../../infra/outbound/session-binding-service.js", () => ({
|
||||
unbind: vi.fn(async () => []),
|
||||
}),
|
||||
}));
|
||||
vi.mock("../../bindings/records.js", () => ({
|
||||
resolveConversationBindingRecord: (conversation: {
|
||||
channel: string;
|
||||
accountId: string;
|
||||
conversationId: string;
|
||||
parentConversationId?: string;
|
||||
}) => sessionBindingMocks.resolveByConversation(conversation),
|
||||
touchConversationBindingRecord: (...args: [bindingId: string, at?: number]) =>
|
||||
sessionBindingMocks.touch(...args),
|
||||
}));
|
||||
vi.mock("../../infra/agent-events.js", () => ({
|
||||
emitAgentEvent: (params: unknown) => agentEventMocks.emitAgentEvent(params),
|
||||
onAgentEvent: (listener: unknown) => agentEventMocks.onAgentEvent(listener),
|
||||
@@ -594,6 +604,11 @@ const automaticGroupReplyConfig = {
|
||||
},
|
||||
},
|
||||
} as const satisfies OpenClawConfig;
|
||||
const automaticDirectReplyConfig = {
|
||||
messages: {
|
||||
visibleReplies: "automatic",
|
||||
},
|
||||
} as const satisfies OpenClawConfig;
|
||||
let dispatchReplyFromConfig: typeof import("./dispatch-from-config.js").dispatchReplyFromConfig;
|
||||
let dispatchFromConfigTesting: typeof import("./dispatch-from-config.js").testing;
|
||||
let resetInboundDedupe: typeof import("./inbound-dedupe.js").resetInboundDedupe;
|
||||
@@ -854,6 +869,28 @@ function firstRouteReplyCall(): Record<string, unknown> {
|
||||
return call as Record<string, unknown>;
|
||||
}
|
||||
|
||||
function installThreadingTestPlugin(params: { defaultAccountId?: string; id: string }) {
|
||||
const plugin = createChannelTestPluginBase({ id: params.id });
|
||||
const defaultAccountId = params.defaultAccountId;
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
pluginId: params.id,
|
||||
source: "test",
|
||||
plugin: {
|
||||
...plugin,
|
||||
config: defaultAccountId
|
||||
? { ...plugin.config, defaultAccountId: () => defaultAccountId }
|
||||
: plugin.config,
|
||||
threading: {
|
||||
resolveReplyToMode: () => "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
function requireToolResultHandler(
|
||||
handler: GetReplyOptions["onToolResult"] | undefined,
|
||||
): NonNullable<GetReplyOptions["onToolResult"]> {
|
||||
@@ -945,6 +982,25 @@ describe("dispatchReplyFromConfig", () => {
|
||||
),
|
||||
},
|
||||
};
|
||||
const passiveThreadingTestPlugins = [
|
||||
"slack",
|
||||
"telegram",
|
||||
"feishu",
|
||||
"mattermost",
|
||||
"imessage",
|
||||
].map((id) => {
|
||||
const plugin = createChannelTestPluginBase({ id });
|
||||
return {
|
||||
pluginId: id,
|
||||
source: "test" as const,
|
||||
plugin: {
|
||||
...plugin,
|
||||
threading: {
|
||||
resolveReplyToMode: () => "all" as const,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
setActivePluginRegistry(
|
||||
createTestRegistry([
|
||||
{
|
||||
@@ -957,6 +1013,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
source: "test",
|
||||
plugin: signalTestPlugin,
|
||||
},
|
||||
...passiveThreadingTestPlugins,
|
||||
]),
|
||||
);
|
||||
clearApprovalNativeRouteStateForTest();
|
||||
@@ -1209,7 +1266,8 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("does not route when Provider matches OriginatingChannel (even if Surface is missing)", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
const cfg = emptyConfig;
|
||||
installThreadingTestPlugin({ id: "slack", defaultAccountId: "work" });
|
||||
const cfg = automaticDirectReplyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "slack",
|
||||
@@ -1227,11 +1285,23 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
expect(mocks.routeReply).not.toHaveBeenCalled();
|
||||
expect(dispatcher.sendFinalReply).toHaveBeenCalledTimes(1);
|
||||
const replyDispatchCall = firstMockCall(hookMocks.runner.runReplyDispatch, "reply dispatch") as
|
||||
| [
|
||||
{
|
||||
originatingAccountId?: unknown;
|
||||
shouldRouteToOriginating?: unknown;
|
||||
},
|
||||
unknown,
|
||||
]
|
||||
| undefined;
|
||||
expect(replyDispatchCall?.[0]?.shouldRouteToOriginating).toBe(false);
|
||||
expect(replyDispatchCall?.[0]?.originatingAccountId).toBe("work");
|
||||
});
|
||||
|
||||
it("mirrors ownerless same-channel Slack finals after successful delivery", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "slack" });
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
Provider: "slack",
|
||||
@@ -1273,6 +1343,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
it("mirrors reset acknowledgements into the canonically prepared Slack session", async () => {
|
||||
setNoAbort();
|
||||
hookMocks.runner.hasHooks.mockReturnValue(false);
|
||||
const dispatcher = createDispatcher();
|
||||
const sessionKey = "Agent:Main:Slack:Channel:C123";
|
||||
const preparedSessionKey = "agent:main:slack:channel:c123";
|
||||
@@ -1352,6 +1423,8 @@ describe("dispatchReplyFromConfig", () => {
|
||||
setNoAbort();
|
||||
const dispatcher = createDispatcher();
|
||||
mocks.routeReply.mockClear();
|
||||
hookMocks.runner.hasHooks.mockReturnValue(false);
|
||||
installThreadingTestPlugin({ id: "telegram", defaultAccountId: "default" });
|
||||
|
||||
const result = await dispatchReplyFromConfig({
|
||||
ctx: buildTestCtx({
|
||||
@@ -1359,9 +1432,10 @@ describe("dispatchReplyFromConfig", () => {
|
||||
Surface: "slack",
|
||||
OriginatingChannel: "telegram",
|
||||
OriginatingTo: "telegram:999",
|
||||
AccountId: "default",
|
||||
SessionKey: "agent:main:telegram:group:999",
|
||||
}),
|
||||
cfg: emptyConfig,
|
||||
cfg: automaticDirectReplyConfig,
|
||||
dispatcher,
|
||||
replyResolver: async () =>
|
||||
setReplyPayloadMetadata(
|
||||
@@ -1581,6 +1655,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
it("keeps non-Slack routed direct turns behind the active reply operation", async () => {
|
||||
setNoAbort();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const sessionKey = "agent:main:telegram:direct:1";
|
||||
const activeOperation = createReplyOperation({
|
||||
sessionKey,
|
||||
@@ -1627,6 +1702,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("routes when OriginatingChannel differs from Provider", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -1667,6 +1743,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("routes exec-event replies using persisted session delivery context when current turn has no originating route", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
sessionStoreMocks.currentEntry = {
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
@@ -1724,6 +1801,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("routes sessions_send internal webchat handoffs through persisted external delivery context", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "feishu" });
|
||||
sessionStoreMocks.currentEntry = {
|
||||
route: {
|
||||
channel: "feishu",
|
||||
@@ -1835,6 +1913,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("honors sendPolicy deny for recovered exec-event delivery channel", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
sessionStoreMocks.currentEntry = {
|
||||
deliveryContext: {
|
||||
channel: "telegram",
|
||||
@@ -1908,6 +1987,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("uses Slack DM TransportThreadId when ReplyToId is the current message", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "slack" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -1935,6 +2015,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("does not resurrect a cleared route thread from origin metadata", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "mattermost" });
|
||||
// Simulate the real store: lastThreadId and deliveryContext.threadId may be normalised from
|
||||
// origin.threadId on read, but a non-thread session key must still route to channel root.
|
||||
sessionStoreMocks.currentEntry = {
|
||||
@@ -1975,6 +2056,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
it("forces suppressTyping when routing to a different originating channel", async () => {
|
||||
setNoAbort();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -2015,6 +2097,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("routes when provider is webchat but surface carries originating channel metadata", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -2036,6 +2119,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("routes Feishu replies when provider is webchat and origin metadata points to Feishu", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "feishu" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -2076,6 +2160,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("does not route external origin replies when current surface is internal webchat without explicit delivery", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "imessage" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -2099,6 +2184,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("routes external origin replies for internal webchat turns when explicit delivery is set", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "imessage" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -2128,6 +2214,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("routes media-only tool results when summaries are suppressed", async () => {
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const cfg = automaticGroupReplyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -5497,6 +5584,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
|
||||
it("deduplicates same-agent inbound replies across main and direct session keys", async () => {
|
||||
setNoAbort();
|
||||
hookMocks.runner.hasHooks.mockReturnValue(false);
|
||||
const cfg = emptyConfig;
|
||||
const replyResolver = vi.fn(async () => ({ text: "hi" }) as ReplyPayload);
|
||||
const baseCtx = buildTestCtx({
|
||||
@@ -5530,6 +5618,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
it("emits message_received hook with originating channel metadata", async () => {
|
||||
setNoAbort();
|
||||
hookMocks.runner.hasHooks.mockReturnValue(true);
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -5789,6 +5878,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
// would receive divergent keys on every native redirect.
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -5825,6 +5915,7 @@ describe("dispatchReplyFromConfig", () => {
|
||||
// generalization of the native-redirect branch.
|
||||
setNoAbort();
|
||||
mocks.routeReply.mockClear();
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const cfg = emptyConfig;
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = buildTestCtx({
|
||||
@@ -7669,6 +7760,7 @@ describe("before_dispatch hook", () => {
|
||||
it("uses canonical hook metadata and shared routed final delivery", async () => {
|
||||
ttsMocks.state.synthesizeFinalAudio = true;
|
||||
hookMocks.runner.runBeforeDispatch.mockResolvedValue({ handled: true, text: "Blocked" });
|
||||
installThreadingTestPlugin({ id: "telegram" });
|
||||
const dispatcher = createDispatcher();
|
||||
const ctx = createHookCtx({
|
||||
Body: "raw body",
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user