Compare commits

..

42 Commits

Author SHA1 Message Date
Alex Knight
37215d16eb test(agents): update cron prompt snapshots 2026-06-16 20:13:31 +10:00
Alex Knight
4e5b8f624c fix(cron): floor transient retry re-arms 2026-06-16 20:13:30 +10:00
Alex Knight
47ec08016b feat(cron): enforce cron.minInterval with a fire-time floor
Creation-time validation stays as early feedback; the scheduler now paces
all nextRunAtMs writers (completion, error backoff, update re-arm,
maintenance recompute, restart catch-up and backoff defer) so consecutive
fires of recurring jobs stay at least cron.minInterval apart - covering
pre-existing jobs and cron expressions that bounded sampling cannot prove.
Manual force runs preserve the schedule; dispatch slack keeps at-floor
schedules on their natural slots.
2026-06-16 20:13:30 +10:00
Alex Knight
45843ac055 feat(cron): add configurable minimum interval guardrail for recurring jobs
Add a cron.minInterval config option that rejects creating or editing
recurring every/cron jobs whose tightest fire interval is below an
operator-configured floor. Mirrors the existing cron.maxConcurrentRuns
guardrail pattern: resolved via resolveCronMinIntervalMs and enforced in
the cron service (createJob and schedule-changing updates) so every
caller path (agent tool, CLI, gateway RPC) gets a clear rejection.

- Accepts a duration string (30s, 5m, 1h) or milliseconds; 0/unset disables.
- One-shot at jobs are exempt; cron expressions judged by smallest gap
  across sampled consecutive fires (catches irregular bursts like 0,1 * * * *).
- Wires zod schema validation, help/label entries, docs, and a tool-
  description note so the agent backs off instead of retrying.
- Maps the rejection to INVALID_REQUEST in the gateway cron handler.
2026-06-16 20:13:30 +10:00
Vincent Koc
b500a488e4 fix(ci): support Anthropic OAuth release validation 2026-06-16 18:10:49 +08:00
Vincent Koc
645fe838ff fix(ci): align checkout guard timeout 2026-06-16 12:10:23 +02:00
Vincent Koc
4fee348764 refactor(agents): remove unused credential comparator 2026-06-16 18:08:32 +08:00
Vincent Koc
0471275270 refactor(agents): remove unused process registry export 2026-06-16 18:07:18 +08:00
Vincent Koc
203bddcdb7 refactor(agents): drop unused truncation export 2026-06-16 18:06:05 +08:00
Vincent Koc
c6d549c5a7 test(ci): update checkout timeout guard 2026-06-16 18:04:58 +08:00
Alix-007
176572cb35 fix(skills): clear orphaned idempotency pointer on corrupt-metadata re-begin (#93509)
Merged via squash.

Prepared head SHA: 0dd53d2dac
Co-authored-by: Alix-007 <267018309+Alix-007@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 18:04:47 +08:00
Vincent Koc
55c047e77e fix(ci): relax checkout fetch timeout 2026-06-16 17:51:06 +08:00
Vincent Koc
58a8142a33 chore(deadcode): drop duplicate unused-file allowlist entry 2026-06-16 17:35:41 +08:00
Vincent Koc
2e7caba557 refactor(plugins): reuse dependency status core 2026-06-16 17:35:41 +08:00
Vincent Koc
0fd0e7cb92 fix(ci): align main CI fixtures 2026-06-16 17:33:39 +08:00
xiayu
a89e6e05ef fix(cli): summarize cleanup dry-run by label (#93565)
Merged via squash.

Prepared head SHA: b0dd1d0833
Co-authored-by: AgentArcLab <19233945+AgentArcLab@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 17:22:54 +08:00
Vincent Koc
08ff253e5f fix(ci): repair test helper type checks 2026-06-16 11:12:21 +02:00
Vincent Koc
033bb86133 fix(scripts): update plugin SDK surface budget 2026-06-16 11:01:41 +02:00
Harjoth Khara
790e00a303 fix(agents): honor embedded run default model (#93439)
Merged via squash.

Prepared head SHA: 171165c3eb
Co-authored-by: harjothkhara <48686985+harjothkhara@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 16:55:58 +08:00
Matt Gunnin
2cfcb3c932 AGT-80 AGT-81 Fix Discord ingress ack ordering (#93407)
Merged via squash.

Prepared head SHA: 55718a24fd
Co-authored-by: mgunnin <321368+mgunnin@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 16:54:46 +08:00
ZengWen-DT
9ed9d389e0 fix(feishu): dedupe redelivered text by stable retry identity (#93449)
Merged via squash.

Prepared head SHA: 230266c0ba
Co-authored-by: ZengWen-DT <290981215+ZengWen-DT@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 16:53:29 +08:00
Bé Mi Agent
d697ecf172 fix: avoid parent group allowlist false positive (#93434)
Merged via squash.

Prepared head SHA: da2ce686af
Co-authored-by: kingrubic <116256161+kingrubic@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 16:51:04 +08:00
Vincent Koc
6d22b8eb24 fix(ci): repair main type and lint checks 2026-06-16 10:43:21 +02:00
WhatsSkiLL
c14793d35a fix(gateway): project failed agent turns in chat history (#89483)
Merged via squash.

Prepared head SHA: d7b510a90d
Co-authored-by: IWhatsskill <284122573+IWhatsskill@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 16:35:21 +08:00
Vincent Koc
f90ec6d7be fix(tests): avoid runtime discovery in routed reply checks 2026-06-16 10:21:46 +02:00
snowzlm
1a002c2d9d fix(agents): preserve prompt-released session state (#93194)
Preserve concurrent prompt-time transcript updates across stale session managers, side appends, transcript navigation, nested owned writes, and doctor repair.

Fixes #93193.

Thanks @snowzlm for the report and original fix.

Co-authored-by: snowzlm <snowzlm@noreply.codeberg.org>
2026-06-16 10:21:01 +02:00
Sebastien Tardif
a55f625b09 fix(discord): resolve guildId from session channel for search actions (#88796)
Merged via squash.

Prepared head SHA: 6b0c282908
Co-authored-by: SebTardif <1413412+SebTardif@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 16:15:12 +08:00
sunlit-deng
21d3a70826 fix(plugins): load externally-installed channel plugins at gateway startup (#93470)
Merged via squash.

Prepared head SHA: 934dfd3c57
Co-authored-by: sunlit-deng <253064511+sunlit-deng@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 16:13:42 +08:00
Vincent Koc
9b49387ad8 fix(tui): refresh after external session reset (#93562)
* fix(tui): refresh after external session reset

* fix(clownfish): address review for ghcrawl-157015-autonomous-smoke (1)

Co-authored-by: Jason <31175216+wsyjh8@users.noreply.github.com>

* fix(clownfish): address review for ghcrawl-157015-autonomous-smoke (1)

Co-authored-by: Jason <31175216+wsyjh8@users.noreply.github.com>

---------

Co-authored-by: openclaw-clownfish[bot] <280122609+openclaw-clownfish[bot]@users.noreply.github.com>
Co-authored-by: Jason <31175216+wsyjh8@users.noreply.github.com>
2026-06-16 16:10:37 +08:00
Goutam Adwant
7e9b9421bd fix(codex): log app-server compaction completion (#93463)
Merged via squash.

Prepared head SHA: 49f4423dd7
Co-authored-by: goutamadwant <8672451+goutamadwant@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 16:05:29 +08:00
jason
ff5e73539a fix(agents): drop partialJson streaming artifacts from session history repair (#93469)
Merged via squash.

Prepared head SHA: 86fe9d5a43
Co-authored-by: drvoss <3031622+drvoss@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 16:03:34 +08:00
zhang-guiping
b5648b1d5e fix(cli): skip compile cache on early Node 24.x to avoid startup deadlock (#89799)
Merged via squash.

Prepared head SHA: 46341b26fb
Co-authored-by: zhangguiping-xydt <275915537+zhangguiping-xydt@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 15:59:34 +08:00
weiqinl
0ab4cd7c52 fix(bedrock): strip inference profile prefix from model ID in embedding adapter (#93452)
Merged via squash.

Prepared head SHA: aaaee01ebe
Co-authored-by: LiuwqGit <7065327+LiuwqGit@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 15:57:40 +08:00
ooiuuii
042ebb4f75 fix(cli): accept --log-level after subcommands (#93455)
Merged via squash.

Prepared head SHA: b6d3aa5719
Co-authored-by: ooiuuii <169449607+ooiuuii@users.noreply.github.com>
Co-authored-by: vincentkoc <25068+vincentkoc@users.noreply.github.com>
Reviewed-by: @vincentkoc
2026-06-16 15:48:19 +08:00
Vincent Koc
1ae0eacf4b fix(scripts): avoid downgrade release upgrade baselines 2026-06-16 09:35:39 +02:00
Vincent Koc
c06b7959ec fix(proxy): preserve in-memory capture databases 2026-06-16 09:22:48 +02:00
Vincent Koc
aeb5b794c9 fix(sqlite): tolerate unsupported private modes 2026-06-16 09:22:48 +02:00
Vincent Koc
e83926747c fix(proxy): keep capture storage private 2026-06-16 09:22:48 +02:00
Vincent Koc
e51c0c8cea fix(sqlite): include rollback journals in security paths 2026-06-16 09:22:48 +02:00
Vincent Koc
67c55ccce8 fix(e2e): avoid Linux snapshot apt races 2026-06-16 15:18:44 +08:00
Yuan
385d1ada91 fix(feishu): drop self-authored receive events (#90572) 2026-06-16 15:11:51 +08:00
Yuval Dinodia
7fc124dcf1 fix(reply): preserve pending thread evidence when reconciling partial send results (#93291)
* fix(reply): preserve pending thread evidence when reconciling partial send results

extractMessagingToolSendResult re-derived threadId/threadImplicit/threadSuppressed
straight from the provider result. Mattermost is the only production provider that
implements extractToolSendResult, and for an implicitly threaded send it reports only
{ to }, so the reconciler overwrote the correct pending thread evidence with undefined.
That defeated same-thread reply suppression in reply-payloads dedupe and delivered the
agent's final reply twice in the thread, on both the native and Codex harnesses.

A partial provider result now keeps the pending thread evidence it does not speak to: a
provider-reported threadId still wins (and clears the implicit flag), but an absent one
no longer erases the pending threadId/threadImplicit/threadSuppressed.

Regression introduced by c67dc59b02 (#90943).

* test(reply): use a core-local stub provider instead of the bundled Mattermost import

The reconcile-thread regression test deep-imported extensions/mattermost from a
core test, which trips the core/extension package boundary (boundary-invariants
"keeps core tests off bundled extension deep imports", extension-test-boundary,
and check-tsgo-core-boundary pulling extensions/mattermost transitively).

Replace it with a core-local channel test plugin that reproduces the same
contract: an implicit-threading extractToolSend, a partial extractToolSendResult
that reports only { to, threadId? }, and no targetsMatchForReplySuppression
matcher. The test now exercises the generic reconciler contract with no
extension dependency. It still fails on pristine main and passes with the fix.

* fix(reply): reconcile thread evidence atomically

---------

Co-authored-by: Vincent Koc <25068+vincentkoc@users.noreply.github.com>
2026-06-16 15:10:00 +08:00
194 changed files with 15553 additions and 1998 deletions

View File

@@ -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.

View File

@@ -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}` }),
},
};

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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 }}

View File

@@ -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)

View File

@@ -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`.

View File

@@ -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:

View File

@@ -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.

View File

@@ -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`.

View File

@@ -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.

View File

@@ -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.

View File

@@ -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

View File

@@ -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",
);
});
});

View File

@@ -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,
};
// ---------------------------------------------------------------------------

View File

@@ -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.",

View File

@@ -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(

View File

@@ -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: {

View File

@@ -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 {

View File

@@ -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;

View File

@@ -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,

View 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" },
]);
});
});

View File

@@ -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",

View File

@@ -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[];

View File

@@ -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(

View File

@@ -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)

View File

@@ -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"),

View File

@@ -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();
});
});

View File

@@ -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 });

View File

@@ -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();

View File

@@ -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: {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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

View File

@@ -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 = {

View File

@@ -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,

View 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"]),
);
});
});

View File

@@ -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;
}

View 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"]);
});
});

View File

@@ -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}`);

View File

@@ -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());

View File

@@ -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");
});
});

View File

@@ -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}`,

View File

@@ -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]);
});
});

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -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>;

View File

@@ -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 \

View File

@@ -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",
];

View File

@@ -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=""

View File

@@ -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"]);
}

View File

@@ -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"

View 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`);
}

View File

@@ -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",

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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();

View File

@@ -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 : [];

View File

@@ -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);

View File

@@ -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({

View File

@@ -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 [];
}

View File

@@ -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([]);

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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);

View File

@@ -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

View File

@@ -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)),
},
);
}

View File

@@ -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,

View File

@@ -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;

View File

@@ -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.

View File

@@ -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,
},

View File

@@ -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"],

View File

@@ -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");

View File

@@ -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;
}

View File

@@ -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);

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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);
});
});

View File

@@ -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,
};
}

View File

@@ -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([
{

View File

@@ -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) {

View File

@@ -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

View File

@@ -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);
});
});

View File

@@ -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:

View File

@@ -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) => {

View File

@@ -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();
}

View File

@@ -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" },
]);
});
});

View File

@@ -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"));

View File

@@ -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,

View File

@@ -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> {

View File

@@ -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