fix(models): preserve session selection across fallback turns (#119325)

* feat(models): add session-only model selection

* fix(models): use trailing session scope option

* test(models): satisfy session scope lint

* fix(models): reject duplicate model options

* fix(models): clarify default and session scope

* fix(models): require complete session option tokens

* fix(models): report configured default dispatch

* fix(models): keep directive handler within lint limit

* fix(models): parse model options in either order

* fix(models): apply session scope to aliases

* fix(models): align alias scope with reply routing

* fix(discord): surface model selection scope in picker

* fix(models): preserve mixed-text model selection

* fix(models): centralize command selection ownership

* fix(models): align session scope lifecycle

* fix(models): preserve command and auth ownership

* fixup! fix(models): preserve command and auth ownership

* fix(auth): preserve scoped CLI provider discovery

* test(models): align result and cron fixtures

* test(models): nest result timing metadata

* fix(discord): narrow silent dispatch results

* fix(transcript): preserve admitted turn identity

* fix(context-engine): fence the admitted transcript turn

* fix(context-engine): stabilize plugin compatibility contract

* chore(plugin-sdk): refresh context engine API baseline

* chore(plugin-sdk): use Linux context engine API baseline

* fix(context-engine): align fallback ownership

* fix(fallback): scope auth skip cache by profile

* fix(context-engine): settle only accepted fallback turns

* refactor(sessions): issue canonical turn admissions

* refactor(context-engine): own logical turn advancement

* fix(context-engine): settle cron fallback winners

* fix(models): align picker and fallback transactions

* fix(delivery): notify block admission after queueing

* fix(sessions): preserve canonical admission receipts

* chore(plugin-sdk): refresh API baseline hash

* fix(context-engine): commit accepted turns durably

* fix(context-engine): validate durable host transitions

* fix(context-engine): preserve fallback turn ownership

* fix(context-engine): preserve queued turn order

* fix(models): preserve fallback retry ownership

* fix(context-engine): enforce durable transcript anchors

* fix(runtime): close fallback persistence gaps

* fix(context-engine): preflight fallback harnesses

* chore(plugin-sdk): use Linux API baseline

* fix(context-engine): drain durable commits before reads

* fix(models): scope harness auth failures by profile

* fix(codex): fence legacy transcript history

* fix(commands): honor suppressed directive interpretation

* chore(runtime): remove unused branch exports

* test(context-engine): derive private outbox payload type

* fix(context-engine): apply durable drain degradation

* fix(context-engine): recover durable turn intents

* fix(context-engine): settle durable turn intents

* refactor(context-engine): satisfy branch quality gates

* fix(context-engine): close durable recovery gaps

* fix(discord): preserve dropped model command outcome

* test(copilot): keep journal fixture types local

* fix(auto-reply): preserve model alias provenance

* fix: close model scope review gaps

* fix(models): close review-found scope leaks

* fix(review): satisfy branch line budgets

* fix(agents): preserve context engine turn facts

* fix(agents): finalize silent context turns

* fix(context-engine): preserve compatibility window

* test(agents): cover both harness preparations

* fix(context-engine): retain blocked turn advancements

* fix(models): parse compact runtime options

* fix(telegram): report runtime resets accurately

* fix(models): isolate automatic auth failure skips

* fix(context-engine): project commit turn host params

---------

Co-authored-by: VACInc <3279061+VACInc@users.noreply.github.com>
Co-authored-by: Vincent Koc <vincentkoc@ieee.org>
This commit is contained in:
Vito Cappello
2026-08-07 04:19:12 -04:00
committed by GitHub
parent 681076c491
commit 5621979a46
218 changed files with 11601 additions and 2359 deletions
@@ -4,9 +4,9 @@ cbf4e2c3088f8886a7c9ea91325a66e0f0846cea21f0b2891f36399b4811306c module/account
8e985f345f21a1c9a2b0e94304aaaad6a326bec1c1ce3b26027d2862804a366e module/account-resolution
e5e67ddf3cab38fcbf9220bc3160715897e2709d9a9ff6ff36f1ecc9453c2367 module/agent-config-primitives
74daa746deb548379d3f0d6eac3c4d082df1034c4360cc03bf51fee0f10a2e4d module/agent-harness
7d5db072119e8bff37bcb0086e833b047ef11ba4d713ffc9041dfb6ae6e22d70 module/agent-harness-runtime
50893fb8eac4090735ecb677c96180690804fb7354fd4e88147f3f956cefab88 module/agent-harness-runtime
5168648cd946abad8a92822889f13ceacc87ed502314a66190d0b1eb8ebe76ea module/agent-media-payload
6ee8bb70cd7b8a5a976ee84cd0c6e632dbfa5e4a047f616cdc00fa5b27879b27 module/agent-runtime
e82bf122ca0787ec0bcdd12b669cfe24f07da16be1f4090e84c7d645b07ac79f module/agent-runtime
56b6d5fb6af3d95af1200065aca2e7d4f59e5fa59740505fe6ff433077ef6646 module/allow-from
55cea5390d68839ca7768b4a0cc570b17b65fa0fa3bc4d76130ef0f16cb79ede module/allowlist-config-edit
7ddd81bd5f55de9adf64bf4d92d012f24b37b6da0a72805a3a220d8feff24ca3 module/approval-auth-runtime
+2 -2
View File
@@ -28,7 +28,7 @@ Stock Matrix clients keep rendering the plain text `body`. OpenClaw-aware client
"options": [
{
"label": "DeepSeek",
"value": "/model deepseek/deepseek-chat"
"value": "/model deepseek/deepseek-chat -s"
}
]
}
@@ -69,7 +69,7 @@ The Matrix outbound adapter advertises native support for:
This metadata does not add Matrix callback semantics. Button and select values are fallback interaction payloads, usually slash commands or text commands. A Matrix client that wants to support interaction resolves the control value (`action.command`, then `action.value`, then `value`) and sends it back to the room as a normal message.
For example, a button with value `/model deepseek/deepseek-chat` can be handled by sending that value as an encrypted Matrix text message in the same room.
For example, a button with value `/model deepseek/deepseek-chat -s` can be handled by sending that value as an encrypted Matrix text message in the same room. The explicit session flag prevents a presentation control from requesting a configured-default update.
## Relationship to approval metadata
+35 -1
View File
@@ -129,7 +129,11 @@ export default function register(api) {
id: "my-engine",
name: "My Context Engine",
ownsCompaction: true,
acceptedHostParams: ["sessionKey"],
acceptedHostParams: ["sessionKey", "runtimeContext"],
transcriptSemantics: {
currentTurnFence: "before-current-turn-entry-v1",
turnAdvancementIdempotency: "atomic-idempotent-v1",
},
},
async ingest({ sessionId, message, isHeartbeat }) {
@@ -161,6 +165,16 @@ export default function register(api) {
// Summarize older context
return { ok: true, compacted: true };
},
async commitTurn({ advancementKey, messages, prePromptMessageCount }) {
// Atomically store the accepted turn and advancementKey. Return
// "duplicate" when that exact key was committed by an earlier retry.
return await commitAcceptedTurn({
advancementKey,
messages,
prePromptMessageCount,
});
},
}));
}
```
@@ -208,6 +222,26 @@ are never injected. Engines without this declaration receive the pre-host-field
legacy parameter set through 2026-08-12; after that date, undeclared engines
receive every current host field.
For durable admitted turns, declare both transcript semantics:
- `currentTurnFence: "before-current-turn-entry-v1"`
- `turnAdvancementIdempotency: "atomic-idempotent-v1"`
and implement `commitTurn(...)` as one atomic, idempotent write keyed by
`advancementKey`. Return `{ status: "committed" }` for the first write and
`{ status: "duplicate" }` when a host retry presents an already-committed key.
Pre-turn transcript reads during bootstrap, maintenance, assembly, and retries
then see the exact transcript prefix before the admitted user message. The host
calls `commitTurn` only for the accepted successful turn; failed or aborted
turns do not advance context-engine state.
Without the full declaration and method, OpenClaw uses the legacy context path
for the whole logical turn, including retries. The configured context-engine
slot is not changed, and OpenClaw tries the configured engine again on the next
logical turn. The same turn-local degradation applies if a declared fence
cannot be honored because its exact admitted message is missing, rewritten, or
already crossed by a transcript cursor.
`assemble` returns an `AssembleResult` with:
<ParamField path="messages" type="Message[]" required>
+4 -4
View File
@@ -64,7 +64,7 @@ Opt in to suppress repeat auth failures with:
OPENCLAW_FALLBACK_SKIP_TTL_MS=60000
```
When enabled, OpenClaw records an in-memory, session-scoped skip marker for a non-primary fallback candidate after an auth-class failure, keyed by session id, provider, and model. Primary candidates are never skipped, so an explicit user model selection still surfaces the real auth error. The cache is process-local and clears on Gateway restart.
When enabled, OpenClaw records an in-memory, session-scoped skip marker for a non-primary fallback candidate after an auth-class failure. The key includes the session, provider, model, and selected automatic or explicit profile ID. Switching profiles does not inherit another profile's failure marker. Primary candidates are never skipped, so an explicit user model selection still surfaces the real auth error. The cache is process-local and clears on Gateway restart.
The value is a TTL in milliseconds. `0` or unset disables the cache. Positive values are clamped between 1 second and 10 minutes.
@@ -139,16 +139,16 @@ If no explicit order is configured, OpenClaw uses a round-robin order:
### Session stickiness (cache-friendly)
OpenClaw **pins the chosen auth profile per session** to keep provider caches warm. It does **not** rotate on every request. The pinned profile is reused until:
OpenClaw **pins the automatically chosen auth profile per session** to keep provider caches warm. It does **not** rotate on every request. An automatic pin may rotate or clear when:
- the session is reset (`/new` / `/reset`)
- a compaction completes (compaction count increments)
- the profile is in cooldown/disabled
Manual selection via `/model …@<profileId>` sets a **user override** for that session and is not auto-rotated until a new session starts.
Manual selection via `/model …@<profileId> -s` sets a **user override**. A valid user pin survives `/new`, `/reset`, session rollover, compaction, and cooldown windows. OpenClaw clears it when the profile disappears, no longer matches the selected provider, or the user selects another explicit profile. `/model default -s` clears the model override while retaining a compatible auth pin and clearing an incompatible one.
<Note>
Auto-pinned profiles (selected by the session router) are treated as a **preference**: they are tried first, but OpenClaw may rotate to another profile on rate limits/timeouts. When the original profile becomes available again, new runs can prefer it again without changing the selected model or runtime. User-pinned profiles stay locked to that profile; if it fails and model fallbacks are configured, OpenClaw moves to the next model instead of switching profiles.
Auto-pinned profiles (selected by the session router) are treated as a **preference**: they are tried first, but OpenClaw may rotate to another profile on rate limits/timeouts. When the original profile becomes available again, new runs can prefer it again without changing the selected model or runtime. User-pinned profiles stay locked on eligible same-provider candidates. A retained pin on the configured default can still move through configured model fallbacks; an explicit user model selection remains strict and reports failure instead.
</Note>
### OpenAI Codex subscription plus API-key backup
+9 -3
View File
@@ -166,18 +166,24 @@ openclaw config set agents.defaults.modelPolicy.allow '["openai/gpt-5.4","anthro
## `/model` in chat
Direct owner/admin `/model <model>` requests **default scope**: it changes this session and starts a best-effort configured-default update. Adding `-s` uses **session scope**: only this session changes. If the agent has no explicit primary model, its effective default is the shared global `agents.defaults.model` fallback.
```text
/model
/model list
/model 3
/model openai/gpt-5.4
/model openai/gpt-5.4 -s
/model default -s
/model default
/model status
```
- `/model` and `/model list` show a compact numbered picker (model family + available providers); `/model <#>` selects from it. On Discord this opens provider/model dropdowns with a Submit step; on Telegram, picker selections are session-scoped and never rewrite the agent's persistent default in `openclaw.json`. `/models add` is deprecated and returns a message instead of registering models from chat.
- `/model` persists the new session selection immediately. If the agent is idle, the next run uses it right away; if a run is already active, the switch is queued for the next clean retry point (or a later one, if tool activity or reply output already started).
- `/model default` clears the session selection so it inherits the configured primary again.
- `/model` and `/model list` show a compact numbered picker (model family + available providers); `/model <#>` selects from it. The Telegram callback picker is session-only. The Discord picker follows the direct command flow, so an owner/admin submission requests a configured-default update. `/models add` is deprecated and returns a message instead of registering models from chat.
- **Configured default:** Direct owner/admin `/model <model>` changes the current session and requests a best-effort update of the effective configured default. OpenClaw targets the agent's explicit primary when one exists; otherwise it targets the shared `agents.defaults.model` fallback. Immutable configuration is left unchanged, and asynchronous write failures are logged without reverting the session selection.
- **Current session only:** `/model <model> -s` (or `--session`) changes the current session without changing either configured default. A non-owner's bare `/model <model>` is also session-only because that caller cannot write configured defaults. An explicit user-selected model and auth profile stay pinned across `/new`, `/reset`, session rollover, compaction, and cooldown windows while they remain valid for the provider; automatic profile pins may rotate or clear.
- **Use the configured default:** `/model default` (with or without `-s`) clears the current session model selection so it inherits the current effective configured default. A compatible auth-profile pin remains; an incompatible pin is cleared. It does not restore an older configured default that a previous owner/admin `/model <model>` replaced.
- If the agent is idle, a model change applies to the next run immediately. If a run is already active, the switch is queued for the next clean retry point (or a later one, if tool activity or reply output already started).
- A user-selected `/model` ref is strict for that session: if it becomes unreachable, the reply fails visibly instead of silently falling back through `agents.defaults.model.fallbacks`. Configured defaults and cron job primaries still use fallback chains.
- `/model status` is the detailed view: auth candidates per provider, and (when configured) the provider endpoint `baseUrl` plus `api` mode.
- Model refs are parsed by splitting on the first `/`; type `provider/model`. If the model ID itself contains `/` (OpenRouter-style), include the provider prefix, e.g. `/model openrouter/moonshotai/kimi-k2`. If you omit the provider, OpenClaw tries: (1) alias match, (2) unique configured-provider match for that exact unprefixed model id, (3) the configured default provider (deprecated fallback) — and if that provider no longer exposes the configured default model, the first configured provider/model instead, to avoid surfacing a stale removed-provider default.
+2 -2
View File
@@ -198,11 +198,11 @@ The auth profile store supports multiple profile IDs for the same provider.
Pick which one is used:
- globally via config ordering (`auth.order`)
- per-session via `/model ...@<profileId>`
- per-session via `/model ...@<profileId> -s`
Example (session override):
- `/model Opus@anthropic:work`
- `/model Opus@anthropic:work -s`
List existing profile IDs with:
+2 -2
View File
@@ -169,10 +169,10 @@ openclaw models auth login --provider anthropic --force
### Per-session (chat command)
- `/model <alias-or-id>@<profileId>` pins a specific provider credential for the current session (example profile ids: `anthropic:default`, `anthropic:work`).
- `/model <alias-or-id>@<profileId> -s` pins a specific provider credential for the current session (example profile ids: `anthropic:default`, `anthropic:work`).
- `/model` (or `/model list`) shows a compact picker; `/model status` shows the full view (candidates + next auth profile, plus provider endpoint details when configured).
If you change auth order or profile pinning for a chat that's already running, send `/new` or `/reset` to start a fresh session — existing sessions keep their current model/profile selection until reset.
Changes to `auth.order` affect automatic profile selection. `/new` and `/reset` clear auto-selected fallback/rotation state but preserve valid explicit user model/profile pins; choose another explicit `@profile` selection to replace a user profile pin.
### Per-agent (CLI override)
+17 -11
View File
@@ -48,7 +48,8 @@ troubleshooting, see the main [FAQ](/help/faq).
<Accordion title="How do I switch models without wiping my config?">
Change only the model fields — avoid full config replaces.
- `/model` in chat (per-session, see [Slash commands](/tools/slash-commands))
- `/model <model> -s` in chat (current session only; see [Slash commands](/tools/slash-commands))
- direct owner/admin `/model <model>` (current session plus a best-effort configured-default update request)
- `openclaw models set ...` (updates just model config)
- `openclaw configure --section model` (interactive)
- edit `agents.defaults.model` in `~/.openclaw/openclaw.json` directly
@@ -86,22 +87,26 @@ troubleshooting, see the main [FAQ](/help/faq).
</Accordion>
<Accordion title="How do I switch models on the fly (without restarting)?">
Send `/model <name>` as a standalone message. See
Send `/model <name> -s` as a standalone message for a temporary switch.
A direct owner/admin `/model <name>` without `-s` also requests a
best-effort configured-default update. See
[Slash commands](/tools/slash-commands) for the
full command list, including the numbered picker (`/model`, `/model
list`, `/model 3`), `/model default` to clear a session override, and
list`, `/model 3`), `/model default` to clear a session model override, and
`/model status` for endpoint/API-mode detail.
Force a specific auth profile per session with `@profile`:
```text
/model opus@anthropic:default
/model opus@anthropic:work
/model opus@anthropic:default -s
/model opus@anthropic:work -s
```
To unpin a profile set with `@profile`, re-run `/model` without the
suffix (e.g. `/model anthropic/claude-opus-4-6`), or pick the default from
`/model`. Use `/model status` to confirm the active auth profile.
A model selection without `@profile` preserves an existing compatible
profile pin. Choose another explicit `@profile` suffix to replace it. Use
`/model status` to inspect the active auth profile. `/model default` keeps
a compatible auth pin and clears one that does not match the configured
default provider.
</Accordion>
@@ -228,7 +233,7 @@ troubleshooting, see the main [FAQ](/help/faq).
}
```
Then `/model gpt`.
Then `/model gpt -s`.
**Option B: separate agents** — Agent A defaults to MiniMax, Agent B
defaults to OpenAI; route by agent or use `/agent` to switch.
@@ -274,8 +279,9 @@ troubleshooting, see the main [FAQ](/help/faq).
}
```
Then `/model sonnet` (or `/<alias>` when supported) resolves to that
model id.
Then `/model sonnet -s` resolves to that model id for the current session.
Omit `-s` only when an owner/admin also wants to request a configured-default
update.
</Accordion>
+2 -1
View File
@@ -142,7 +142,8 @@ First-run Q&A - install, onboard, auth routes, subscriptions, initial failures -
- **Cron jobs**: isolated jobs can set a `model` override per job.
- **Agents**: route tasks to separate agents with different default models, thinking levels, and stream params.
- **On-demand switch**: `/model` switches the current session model at any time.
- **Configured default + current session**: A direct owner/admin `/model <model>` changes the session and requests a best-effort configured-default update. If the agent has no explicit primary model, the target is the shared `agents.defaults.model` fallback.
- **Current session only**: `/model <model> -s` (or `--session`) changes only this session and leaves configured defaults unchanged.
Example - same model, different per-agent settings:
+9
View File
@@ -623,6 +623,15 @@ For an end-to-end authoring guide, see
| `api.registerContextEngine(id, factory)` | Context engine (one active at a time). Declare accepted host-added lifecycle fields with `info.acceptedHostParams`; undeclared engines receive the legacy field set through 2026-08-12, then receive all current host fields. |
| `api.registerMemoryCapability(capability)` | Unified memory capability |
To participate in durable admitted turns, context engines must declare
`currentTurnFence: "before-current-turn-entry-v1"` and
`turnAdvancementIdempotency: "atomic-idempotent-v1"` under
`info.transcriptSemantics`, then implement `commitTurn(...)` as an atomic,
idempotent write keyed by `advancementKey`. Without the full contract, OpenClaw
uses the legacy context path for the whole logical turn and its retries, leaves
the configured engine unchanged, and tries that engine again on the next
logical turn.
### Deprecated memory embedding adapters
| Method | What it registers |
+1 -1
View File
@@ -76,7 +76,7 @@ export BASETEN_API_KEY=...
}
```
Use `/model baseten/thinkingmachines/inkling` to switch an existing chat.
Use `/model baseten/thinkingmachines/inkling -s` to switch the current session.
## Bundled fallback catalog
+1 -1
View File
@@ -442,7 +442,7 @@ for the full example.
```
Use `--profile-id` for multiple Codex OAuth logins in the same agent, then
control them via auth ordering or `/model ...@<profileId>`:
control them via auth ordering or `/model ...@<profileId> -s`:
```bash
openclaw models auth login --provider openai --profile-id openai:ritsuko
+26 -6
View File
@@ -42,6 +42,9 @@ command handling is enabled for the surface.
persist to the session and reply with an acknowledgement.
- In **normal chat** messages with other text, they act as inline hints and
do **not** persist session settings.
Model selection is the exception: an authorized inline `/model` or
configured `/<alias>` persists the session selection, and an owner/admin
selection without `-s` may also request a configured-default update.
- Directives only apply for **authorized senders**. If `commands.allowFrom`
is set, it is the only allowlist used; otherwise authorization comes from
channel allowlists, pairing, and always-on access-group enforcement. Unauthorized
@@ -202,7 +205,7 @@ plugins.
| `/elevated [on\|off\|ask\|full]` | Toggle elevated mode. Alias: `/elev` |
| `/exec host=<auto\|sandbox\|gateway\|node> security=<deny\|allowlist\|full> ask=<off\|on-miss\|always> node=<id>` | Show or set exec defaults |
| `/login [codex\|openai\|openai-codex]` | Pair Codex/OpenAI login from a private chat or Web UI session. Owner/admin only |
| `/model [name\|#\|status]` | Show or set the model |
| `/model [name\|#\|status] [-s\|--session]` | Show or select a model. Direct owner/admin selections request a configured-default update; `-s` changes only this session |
| `/models [provider] [page] [limit=<n>\|all]` | List configured/auth-available providers or models |
| `/queue <mode>` | Manage active-run queue behavior. See [Queue](/concepts/queue) and [Queue steering](/concepts/queue-steering) |
| `/steer <message>` | Inject guidance into the active run. Alias: `/tell`. See [Steer](/tools/steer) |
@@ -217,7 +220,18 @@ plugins.
</Accordion>
<Accordion title="Model switching details">
- `/model` persists the new model immediately to the session.
**Scope in one line:** a direct owner/admin `/model <model>` changes the session and requests a best-effort configured-default update; `-s` changes only the current session. When an agent inherits `agents.defaults.model`, the update target is that shared global fallback.
Configured `/<alias>` shorthands accept the same trailing `--runtime`, `-s`, and `--session` options as `/model <alias>`.
| Goal | Command | Effect |
| --- | --- | --- |
| Request a configured-default change | `/model <model>` as owner/admin | Changes this session and starts a best-effort update of the agent's effective configured default. If the agent has no explicit primary, the target is the shared `agents.defaults.model` fallback |
| Change only this session | `/model <model> -s` (or `--session`) | Changes this session; configured defaults remain unchanged |
| Use the configured default again | `/model default` (with or without `-s`) | Clears this session's model selection so it inherits the current configured default; compatible auth pins remain and incompatible pins clear |
A non-owner `/model <model>` selection is also session-only because it cannot write configured defaults. Immutable configuration stays unchanged, and asynchronous write failures are logged without reverting the session selection. Valid explicit user model/profile pins survive `/new`, `/reset`, session rollover, compaction, and cooldown windows; automatic profile pins may rotate or clear. Resetting with `/model default -s` clears the session model selection, retains a compatible auth pin, and clears an incompatible pin. It does not recover a configured default that an earlier owner/admin selection replaced.
- If the agent is idle, the next run uses it right away.
- If a run is active, the switch is marked pending and applied at the next clean retry point.
@@ -362,18 +376,24 @@ use the Control UI Tools panel or config surfaces.
## `/model`: model selection
Direct owner/admin `/model <model>` requests **default scope**: it changes this session and starts a best-effort configured-default update. Adding `-s` uses **session scope**: only this session changes. For agents without an explicit primary model, the update target is the shared global `agents.defaults.model` fallback.
```text
/model # show model picker
/model list # same
/model 3 # select by number from picker
/model openai/gpt-5.4
/model opus@anthropic:default
/model default # clear the session model selection
/model openai/gpt-5.4 # direct owner/admin: session + default update request
/model openai/gpt-5.4 -s # this session only; configured default unchanged
/model default -s # clear this session's model selection; use configured default
/model opus@anthropic:default -s # pin this profile for the current session
/model default # same reset; does not restore an older configured default
/model status # detailed view with endpoint and API mode
```
On Discord, `/model` and `/models` open an interactive picker with provider and
model dropdowns. The picker respects `agents.defaults.modelPolicy.allow`,
model dropdowns and follow the direct command flow. Owner/admin submissions
request a best-effort configured-default update. Telegram callback-picker
selections are session-only. The picker respects `agents.defaults.modelPolicy.allow`,
including `provider/*` entries. Without an explicit allowlist, model entries and
aliases do not restrict selection.
@@ -20,6 +20,10 @@ import {
prepareMemorySystemPromptAddition,
} from "openclaw/plugin-sdk/core";
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
import type {
SessionTranscriptTargetParams,
TranscriptTurnAdmission,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js";
import type { CodexDynamicToolFunctionSpec, CodexDynamicToolSpec, JsonValue } from "./protocol.js";
import { flattenCodexDynamicToolFunctions } from "./protocol.js";
@@ -78,8 +82,11 @@ export async function readMirroredSessionHistoryMessages(params: {
sessionFile: string;
sessionId: string;
sessionKey?: string;
sessionTarget?: Partial<SessionTranscriptTargetParams>;
admission?: TranscriptTurnAdmission;
}): Promise<AgentMessage[] | undefined> {
const messages = await readCodexMirroredSessionHistoryMessages(params);
const { admission, ...target } = params;
const messages = await readCodexMirroredSessionHistoryMessages(target, admission);
if (!messages) {
embeddedAgentLog.warn("failed to read mirrored session history for codex harness hooks", {
sessionFile: params.sessionFile,
@@ -2,8 +2,12 @@ import {
agentHarnessAttemptTerminal,
type AgentHarnessAttemptResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { TranscriptEntryAnchor } from "openclaw/plugin-sdk/session-transcript-runtime";
export type EmbeddedRunAttemptResult = Extract<AgentHarnessAttemptResult, { terminal: unknown }>;
export type EmbeddedRunAttemptResult = Extract<AgentHarnessAttemptResult, { terminal: unknown }> & {
/** Host-private terminal identity returned to the harness selection boundary. */
contextEngineTerminalAnchor?: TranscriptEntryAnchor;
};
export type AttemptFailureSource = Extract<
EmbeddedRunAttemptResult["terminal"],
{ kind: "failed" }
@@ -66,11 +66,18 @@ export async function prepareCodexAttemptContext(
sessionKey: contextSessionKey,
sessionTarget: params.sessionTarget,
};
const readFencedHistory = async () => {
const transcriptReadFence = params.userTurnTranscriptRecorder?.getAdmissionReceipt();
return await readMirroredSessionHistoryMessages({
...activeTranscriptTarget,
...(transcriptReadFence ? { admission: transcriptReadFence } : {}),
});
};
const historyState = {
messages:
!activeContextEngine && initialStartupBindingHadInactiveThreadBootstrap
? []
: ((await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? []),
: ((await readFencedHistory()) ?? []),
};
const hadSessionTranscriptState = historyState.messages.length > 0;
const hookContextWindowFields = {
@@ -98,9 +105,6 @@ export async function prepareCodexAttemptContext(
...hookContextWindowFields,
};
const hookRunner = getAgentHarnessHookRunner();
const activeContextEnginePluginId = activeContextEngine
? resolveContextEngineOwnerPluginId(activeContextEngine)
: undefined;
const buildActiveContextEngineRuntimeContext = () =>
buildHarnessContextEngineRuntimeContext({
attempt: buildActiveRunAttemptParams(),
@@ -108,7 +112,7 @@ export async function prepareCodexAttemptContext(
cwd: effectiveCwd,
agentDir,
activeAgentId: sessionAgentId,
contextEnginePluginId: activeContextEnginePluginId,
contextEnginePluginId: resolveContextEngineOwnerPluginId(activeContextEngine),
tokenBudget: effectiveContextTokenBudget,
});
if (activeContextEngine) {
@@ -120,6 +124,7 @@ export async function prepareCodexAttemptContext(
sessionFile: activeSessionFile,
sessionTarget: params.sessionTarget,
runtimeContext: buildActiveContextEngineRuntimeContext(),
transcriptReadFence: params.userTurnTranscriptRecorder?.getAdmissionReceipt(),
contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST,
providerId: effectiveRuntimeProviderId,
requestedModelId: usesSupervisionConnection ? undefined : params.requestedModelId,
@@ -130,8 +135,7 @@ export async function prepareCodexAttemptContext(
config: params.config,
warn: (message) => embeddedAgentLog.warn(message),
});
historyState.messages =
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ?? historyState.messages;
historyState.messages = (await readFencedHistory()) ?? historyState.messages;
}
const memoryToolNames = getCodexWorkspaceMemoryToolNames(toolBridge.availableSpecs);
const workspaceBootstrapContext = await buildCodexWorkspaceBootstrapContext({
@@ -1,14 +1,8 @@
import {
buildHarnessContextEngineRuntimeContextFromUsage,
CODEX_APP_SERVER_CONTEXT_ENGINE_HOST,
embeddedAgentLog,
finalizeHarnessContextEngineTurn,
formatErrorMessage,
resolveContextEngineOwnerPluginId,
runAgentHarnessLlmOutputHook,
runHarnessContextEngineMaintenance,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { readMirroredSessionHistoryMessages } from "./attempt-context.js";
import { classifyCodexModelCallFailureKind } from "./attempt-diagnostics.js";
import {
buildCodexAppServerPromptTimeoutOutcome,
@@ -57,15 +51,10 @@ export async function finalizeCodexAttempt(
): Promise<EmbeddedRunAttemptResult> {
const { prompt, state: resourceState, trajectoryRecorder, markTrajectoryEndRecorded } = resources;
const { context, systemPromptReport } = prompt;
const { runtime, attemptTools, activeTranscriptTarget, historyState, hookContext } = context;
const { hookContextWindowFields, hookRunner, promptState } = context;
const { connection, preparedAuthBinding, activeSessionId, activeSessionFile } = runtime;
const {
buildActiveRunAttemptParams,
effectiveContextTokenBudget,
effectiveRuntimeProviderId,
effectiveRuntimeModelId,
} = runtime;
const { runtime, attemptTools, activeTranscriptTarget, hookContext } = context;
const { hookContextWindowFields, hookRunner } = context;
const { connection, preparedAuthBinding } = runtime;
const { effectiveRuntimeProviderId, effectiveRuntimeModelId } = runtime;
const {
params,
terminalState,
@@ -78,8 +67,6 @@ export async function finalizeCodexAttempt(
sessionAgentId,
contextSessionKey,
effectiveCwd,
effectiveWorkspace,
agentDir,
attemptStartedAt,
startupAuthProfileId,
} = connection;
@@ -335,7 +322,8 @@ export async function finalizeCodexAttempt(
threadId: resourceState.thread.threadId,
turnId: activeTurnId,
});
const { assistantTranscriptOwned, assistantTranscriptIdempotencyKey } = mirrorOutcome;
const { assistantTranscriptOwned, assistantTranscriptIdempotencyKey, terminalAnchor } =
mirrorOutcome;
const shouldCaptureSettledTurnFinalizationContext =
turnSucceeded &&
result.assistantTexts.every((text) => !text.trim()) &&
@@ -356,53 +344,6 @@ export async function finalizeCodexAttempt(
turnId: activeTurnId,
});
}
if (activeContextEngine) {
const contextEnginePluginId = resolveContextEngineOwnerPluginId(activeContextEngine);
const isHeartbeat =
params.bootstrapContextRunKind === "heartbeat" ||
params.bootstrapContextRunKind === "commitment-only";
const finalMessages =
(await readMirroredSessionHistoryMessages(activeTranscriptTarget)) ??
historyState.messages.concat(result.messagesSnapshot);
await finalizeHarnessContextEngineTurn({
contextEngine: activeContextEngine,
promptError: Boolean(finalPromptError),
aborted: finalAborted,
yieldAborted: Boolean(result.yieldDetected),
sessionIdUsed: activeSessionId,
sessionKey: contextSessionKey,
sessionFile: activeSessionFile,
sessionTarget: params.sessionTarget,
messagesSnapshot: finalMessages,
prePromptMessageCount: promptState.prePromptMessageCount,
tokenBudget: effectiveContextTokenBudget,
runtimeContext: buildHarnessContextEngineRuntimeContextFromUsage({
attempt: buildActiveRunAttemptParams(),
workspaceDir: effectiveWorkspace,
cwd: effectiveCwd,
agentDir,
activeAgentId: sessionAgentId,
contextEnginePluginId,
tokenBudget: effectiveContextTokenBudget,
lastCallUsage: result.attemptUsage,
promptCache: result.promptCache,
}),
contextEngineHostSupport: CODEX_APP_SERVER_CONTEXT_ENGINE_HOST,
providerId: usesSupervisionConnection
? (resourceState.thread.modelProvider ?? effectiveRuntimeProviderId)
: params.provider,
requestedModelId: usesSupervisionConnection ? undefined : params.requestedModelId,
modelId: usesSupervisionConnection
? (resourceState.thread.model ?? effectiveRuntimeModelId)
: params.modelId,
fallbackReason: usesSupervisionConnection ? undefined : params.fallbackReason,
degradedReason: usesSupervisionConnection ? undefined : params.degradedReason,
runMaintenance: runHarnessContextEngineMaintenance,
config: params.config,
warn: (message) => embeddedAgentLog.warn(message),
isHeartbeat,
});
}
runAgentHarnessLlmOutputHook({
event: {
runId: params.runId,
@@ -552,6 +493,7 @@ export async function finalizeCodexAttempt(
...(promptTimeoutOutcome ? { promptTimeoutOutcome } : {}),
...(assistantTranscriptOwned ? { assistantTranscriptOwned: true } : {}),
...(assistantTranscriptIdempotencyKey ? { assistantTranscriptIdempotencyKey } : {}),
...(terminalAnchor ? { contextEngineTerminalAnchor: terminalAnchor } : {}),
...(settledTurnFinalizationContext ? { settledTurnFinalizationContext } : {}),
...(resourceState.runtimeArtifact ? { runtimeArtifact: resourceState.runtimeArtifact } : {}),
...(!finalAborted && !effectiveTimedOut && !finalPromptError && preparedAuthBinding
@@ -44,6 +44,7 @@ export async function prepareCodexAttemptPrompt(context: CodexAttemptContext) {
historyState,
hookContext,
workspaceBootstrapContext,
buildActiveContextEngineRuntimeContext,
baseDeveloperInstructions,
openClawPromptContext,
skillsCollaborationInstructions,
@@ -108,6 +109,8 @@ export async function prepareCodexAttemptPrompt(context: CodexAttemptContext) {
requestedModelId: usesSupervisionConnection ? undefined : params.requestedModelId,
fallbackReason: usesSupervisionConnection ? undefined : params.fallbackReason,
degradedReason: usesSupervisionConnection ? undefined : params.degradedReason,
runtimeContext: buildActiveContextEngineRuntimeContext(),
transcriptReadFence: params.userTurnTranscriptRecorder?.getAdmissionReceipt(),
prompt: params.prompt,
});
if (!assembled) {
@@ -113,6 +113,7 @@ async function createSqliteParams(
message,
resolveMessage: async () => message,
markRuntimePersisted() {},
getAdmissionReceipt: () => undefined,
} as EmbeddedRunAttemptParams["userTurnTranscriptRecorder"];
return params;
}
@@ -181,6 +182,9 @@ function createContextEngine(overrides: Partial<ContextEngine> = {}): ContextEng
id: "lossless-claw",
name: "Lossless Claw",
ownsCompaction: true,
transcriptSemantics: {
currentTurnFence: "before-current-turn-entry-v1",
},
},
bootstrap: vi.fn(async () => ({ bootstrapped: true })),
assemble: vi.fn(async ({ messages, prompt }) => ({
@@ -1784,7 +1788,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
bootstrapContextRunKind: "heartbeat",
},
] as const)(
"keeps $name turns heartbeat-classified through afterTurn maintenance",
"returns an exact terminal anchor for $name turns without finalizing inside Codex",
async (testCase) => {
const workspaceDir = path.join(tempDir, "workspace");
const afterTurn = vi.fn(
@@ -1808,38 +1812,41 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
await run;
const result = await run;
expect(afterTurn).toHaveBeenCalledTimes(1);
const afterTurnCall = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
NonNullable<ContextEngine["afterTurn"]>
>[0];
expect(afterTurnCall.sessionId).toBe("session-1");
expect(afterTurnCall.sessionKey).toBe("agent:main:session-1");
expect(afterTurnCall.prePromptMessageCount).toBe(0);
expect(afterTurnCall.tokenBudget).toBe(111);
expect(afterTurnCall.isHeartbeat).toBe(true);
expect(afterTurnCall.runtimeSettings).toMatchObject({
runtime: { mode: "degraded" },
model: {
requested: "gpt-5.4-codex-primary",
resolved: "gpt-5.4-codex",
},
diagnostics: {
fallbackReason: "provider_unavailable",
degradedReason: "context_overflow",
},
expect(result.contextEngineTerminalAnchor).toMatchObject({
sessionId: "session-1",
sessionKey: "agent:main:session-1",
});
expect(afterTurnCall.messages.some((message) => message.role === "user")).toBe(true);
expect(afterTurnCall.messages.some((message) => message.role === "assistant")).toBe(true);
expect(maintain).toHaveBeenCalledTimes(1);
const maintainCall = requireFirstCallArg(maintain, "maintain") as Parameters<
NonNullable<ContextEngine["maintain"]>
>[0];
expect(maintainCall.runtimeSettings).toBe(afterTurnCall.runtimeSettings);
expect(afterTurn).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
},
);
it("returns the terminal anchor needed by the outer fallback owner", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const afterTurn = vi.fn(
async (_params: Parameters<NonNullable<ContextEngine["afterTurn"]>>[0]) => undefined,
);
const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
const contextEngine = createContextEngine({ afterTurn, maintain, bootstrap: undefined });
const harness = createStartedThreadHarness();
const params = await createSqliteParams(workspaceDir, "deferred-after-turn");
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await harness.completeTurn();
const result = await run;
expect(afterTurn).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
expect(result.contextEngineTerminalAnchor).toMatchObject({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
});
});
it("reloads mirrored history after bootstrap mutates the session transcript", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -1878,10 +1885,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
"assistant",
"assistant",
]);
const afterTurnParams = requireFirstCallArg(afterTurn, "afterTurn") as Parameters<
NonNullable<ContextEngine["afterTurn"]>
>[0];
expect(afterTurnParams.prePromptMessageCount).toBe(2);
expect(afterTurn).not.toHaveBeenCalled();
expectRequestInputTextContains(harness, "bootstrap context");
});
@@ -1914,7 +1918,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
expect(String(details.error)).not.toContain("sk-abcdefghijklmnopqrstuv");
});
it("falls back to ingestBatch and skips turn maintenance on prompt failure", async () => {
it("does not advance context-engine state on prompt failure", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const ingestBatch = vi.fn(async () => ({ ingestedCount: 2 }));
const maintain = vi.fn(async () => ({ changed: false, bytesFreed: 0, rewrittenEntries: 0 }));
@@ -1933,7 +1937,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await harness.completeTurn("failed");
await run;
expect(ingestBatch).toHaveBeenCalledTimes(1);
expect(ingestBatch).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
});
});
@@ -159,6 +159,24 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
).resolves.toBeUndefined();
});
it("rejects a legacy transcript whose session header belongs to another session", async () => {
const sessionFile = await writeSession([
messageEntry({
id: "foreign",
parentId: null,
role: "assistant",
content: "foreign answer",
}),
]);
await expect(
readCodexMirroredSessionHistoryMessages({
sessionFile,
sessionId: "another-session",
}),
).resolves.toEqual([]);
});
it("replays SQLite marker history by session identity", async () => {
const { marker, sessionKey } = await writeSqliteSession();
@@ -275,6 +293,47 @@ describe("readCodexMirroredSessionHistoryMessages", () => {
]);
});
it("applies the admission fence when session metadata still points to a legacy file", async () => {
const sessionFile = await writeSession([
messageEntry({ id: "prior", parentId: null, role: "user", content: "legacy prior prompt" }),
messageEntry({
id: "current",
parentId: "prior",
role: "user",
content: "legacy current prompt",
}),
]);
const { sessionKey, sessionTarget } = await writeSqliteSession({
storedSessionFile: sessionFile,
});
const admitted = await appendSessionTranscriptMessageByIdentity({
...sessionTarget,
message: { role: "user", content: "sqlite current prompt", timestamp: 3 },
});
if (!admitted?.anchor) {
throw new Error("expected current-turn admission anchor");
}
await expect(
readCodexMirroredSessionHistoryMessages(
{
agentId: sessionTarget.agentId,
sessionFile,
sessionId: sessionTarget.sessionId,
sessionKey,
},
{
...admitted.anchor,
logicalTurnId: "codex-legacy-file-turn",
role: "user",
},
),
).resolves.toMatchObject([
{ role: "user", content: "sqlite prompt" },
{ role: "assistant", content: "sqlite answer" },
]);
});
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" }),
@@ -10,6 +10,7 @@ import {
migrateSessionEntries,
parseSessionEntries,
} from "openclaw/plugin-sdk/agent-sessions";
import { readCodexSessionTranscriptEventsBeforeAdmission } from "openclaw/plugin-sdk/codex-session-transcript-runtime";
import {
getSessionEntry,
parseSqliteSessionFileMarker,
@@ -18,6 +19,7 @@ import {
} from "openclaw/plugin-sdk/session-store-runtime";
import {
readSessionTranscriptEvents,
type TranscriptTurnAdmission,
type SessionTranscriptTargetParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { sanitizeCodexHistoryImagePayloads } from "./image-payload-sanitizer.js";
@@ -37,9 +39,10 @@ export type CodexMirroredSessionHistoryTarget = {
/** Returns sanitized session-context messages for a Codex mirrored session file. */
export async function readCodexMirroredSessionHistoryMessages(
target: CodexMirroredSessionHistoryTarget,
admission?: TranscriptTurnAdmission,
): Promise<AgentMessage[] | undefined> {
try {
const entries = await readCodexMirroredSessionEntries(target);
const entries = await readCodexMirroredSessionEntries(target, admission);
if (entries.length === 0) {
return [];
}
@@ -57,6 +60,9 @@ export async function readCodexMirroredSessionHistoryMessages(
// not a foreign one — keep it on the warn path.
return undefined;
}
if (firstEntry.id !== target.sessionId) {
return [];
}
migrateSessionEntries(entries);
const sessionEntries = entries.filter((entry): entry is SessionEntry => {
return (
@@ -81,6 +87,7 @@ export async function readCodexMirroredSessionHistoryMessages(
async function readCodexMirroredSessionEntries(
target: CodexMirroredSessionHistoryTarget,
admission?: TranscriptTurnAdmission,
): Promise<SessionEntry[]> {
if (target.sessionTarget) {
const { agentId, sessionId, sessionKey, storePath } = target.sessionTarget;
@@ -95,12 +102,15 @@ async function readCodexMirroredSessionEntries(
) {
return [];
}
return (await readSessionTranscriptEvents({
const transcriptTarget = {
agentId,
sessionId,
sessionKey,
storePath,
})) as SessionEntry[];
};
return (await (admission
? readCodexSessionTranscriptEventsBeforeAdmission(transcriptTarget, admission)
: readSessionTranscriptEvents(transcriptTarget))) as SessionEntry[];
}
const sqliteMarker = parseSqliteSessionFileMarker(target.sessionFile);
if (sqliteMarker) {
@@ -114,12 +124,33 @@ async function readCodexMirroredSessionEntries(
if (!sessionKey) {
return [];
}
return (await readSessionTranscriptEvents({
const transcriptTarget = {
agentId: sqliteMarker.agentId,
sessionId: sqliteMarker.sessionId,
sessionKey,
storePath: sqliteMarker.storePath,
})) as SessionEntry[];
};
return (await (admission
? readCodexSessionTranscriptEventsBeforeAdmission(transcriptTarget, admission)
: readSessionTranscriptEvents(transcriptTarget))) as SessionEntry[];
}
if (admission) {
if (
admission.sessionId !== target.sessionId ||
(target.agentId !== undefined && admission.agentId !== target.agentId) ||
(target.sessionKey !== undefined && admission.sessionKey !== target.sessionKey)
) {
return [];
}
return (await readCodexSessionTranscriptEventsBeforeAdmission(
{
agentId: admission.agentId,
sessionId: admission.sessionId,
sessionKey: admission.sessionKey,
storePath: admission.storePath,
},
admission,
)) as SessionEntry[];
}
return parseSessionEntries(await fs.readFile(target.sessionFile, "utf-8")) as SessionEntry[];
}
@@ -0,0 +1,269 @@
import { Buffer } from "node:buffer";
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { CodexThread, JsonValue } from "./protocol.js";
import { attachCodexMirrorIdentity } from "./upstream-prompt-provenance.js";
const CODEX_HISTORY_IMPORT_MAX_MESSAGES = 200;
const CODEX_HISTORY_IMPORT_MAX_BYTES = 512 * 1024;
const CODEX_HISTORY_IMPORT_MAX_MESSAGE_BYTES = 64 * 1024;
const CODEX_HISTORY_TRUNCATION_SUFFIX = "\n\n[Message truncated during Codex history import.]";
const CODEX_HISTORY_ASSISTANT_API = "openai-chatgpt-responses" as const;
const CODEX_HISTORY_ASSISTANT_PROVIDER = "openai";
const CODEX_HISTORY_ASSISTANT_MODEL = "native-history";
const CODEX_HISTORY_ZERO_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
export type CodexThreadHistoryImportResult = {
importedMessages: number;
omittedMessages: number;
};
type BoundedCodexThreadHistoryProjection = CodexThreadHistoryImportResult & {
responseItems: JsonValue[];
transcriptMessages: AgentMessage[];
};
type ProjectedCodexHistoryMessage = {
message: AgentMessage;
responseItem: JsonValue;
textBytes: number;
};
function isUtf8ContinuationByte(byte: number | undefined): boolean {
return byte !== undefined && (byte & 0xc0) === 0x80;
}
function truncateUtf8Prefix(value: string, maxBytes: number): string {
const bytes = Buffer.from(value);
if (bytes.byteLength <= maxBytes) {
return value;
}
let end = Math.max(0, maxBytes);
while (end > 0 && isUtf8ContinuationByte(bytes[end])) {
end -= 1;
}
return bytes.subarray(0, end).toString("utf8");
}
function normalizeImportedHistoryText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const text = value.trim();
if (!text) {
return undefined;
}
if (Buffer.byteLength(text, "utf8") <= CODEX_HISTORY_IMPORT_MAX_MESSAGE_BYTES) {
return text;
}
const suffixBytes = Buffer.byteLength(CODEX_HISTORY_TRUNCATION_SUFFIX, "utf8");
const contentLimitBytes = Math.max(0, CODEX_HISTORY_IMPORT_MAX_MESSAGE_BYTES - suffixBytes);
return `${truncateUtf8Prefix(text, contentLimitBytes)}${CODEX_HISTORY_TRUNCATION_SUFFIX}`;
}
function projectCodexUserItemText(item: Record<string, unknown>): string | undefined {
if (!Array.isArray(item.content)) {
return undefined;
}
const parts: string[] = [];
for (const value of item.content) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
continue;
}
const input = value as Record<string, unknown>;
if (input.type === "text") {
const text = normalizeImportedHistoryText(input.text);
if (text) {
parts.push(text);
}
continue;
}
if (input.type === "image" || input.type === "localImage") {
parts.push("[Image attachment]");
continue;
}
if (input.type === "audio" || input.type === "localAudio" || input.type === "local_audio") {
parts.push("[Audio attachment]");
}
if (input.type === "skill" || input.type === "mention") {
const name = normalizeOptionalString(input.name);
if (name) {
parts.push(`${input.type === "skill" ? "$" : "@"}${name}`);
}
}
}
return normalizeImportedHistoryText(parts.join("\n"));
}
function selectTurnsThroughBoundary(
thread: CodexThread,
throughTurnId: string | null,
): NonNullable<CodexThread["turns"]> {
if (throughTurnId === null) {
return [];
}
const turns = thread.turns ?? [];
const boundaryIndex = turns.findIndex((turn) => turn.id === throughTurnId);
if (boundaryIndex < 0) {
throw new Error(`Codex history boundary turn not found: ${throughTurnId}`);
}
const boundary = turns[boundaryIndex];
if (
boundary?.status !== "completed" &&
boundary?.status !== "interrupted" &&
boundary?.status !== "failed"
) {
throw new Error(`Codex history boundary turn is not terminal: ${throughTurnId}`);
}
return turns.slice(0, boundaryIndex + 1);
}
function projectCodexThreadHistory(params: {
thread: CodexThread;
throughTurnId: string | null;
importedAt: number;
modelProvider?: string;
}): ProjectedCodexHistoryMessage[] {
const projected: ProjectedCodexHistoryMessage[] = [];
const threadTimestamp =
typeof params.thread.createdAt === "number" && Number.isFinite(params.thread.createdAt)
? params.thread.createdAt * 1000
: params.importedAt;
let itemOffset = 0;
for (const turn of selectTurnsThroughBoundary(params.thread, params.throughTurnId)) {
for (const value of turn.items) {
const item = value as unknown as Record<string, unknown>;
const itemId = normalizeOptionalString(item.id);
const identity = `${turn.id}:${itemId ?? itemOffset}`;
const timestampSeconds =
item.type === "agentMessage"
? (turn.completedAt ?? turn.startedAt)
: (turn.startedAt ?? turn.completedAt);
const timestamp =
typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds)
? timestampSeconds * 1000 + itemOffset
: threadTimestamp + itemOffset;
const text =
item.type === "userMessage"
? projectCodexUserItemText(item)
: item.type === "agentMessage"
? normalizeImportedHistoryText(item.text)
: undefined;
const role =
item.type === "userMessage"
? ("user" as const)
: item.type === "agentMessage"
? ("assistant" as const)
: undefined;
itemOffset += 1;
if (!text || !role) {
continue;
}
const message =
role === "assistant"
? attachCodexMirrorIdentity(
{
role,
content: [{ type: "text", text }],
api: CODEX_HISTORY_ASSISTANT_API,
provider:
normalizeOptionalString(params.modelProvider) ??
normalizeOptionalString(params.thread.modelProvider) ??
CODEX_HISTORY_ASSISTANT_PROVIDER,
model: CODEX_HISTORY_ASSISTANT_MODEL,
usage: CODEX_HISTORY_ZERO_USAGE,
stopReason:
turn.status === "interrupted"
? "aborted"
: turn.status === "failed"
? "error"
: "stop",
...(turn.status === "failed" && turn.error?.message
? { errorMessage: turn.error.message }
: {}),
timestamp,
} satisfies AssistantMessage,
identity,
)
: attachCodexMirrorIdentity({ role, content: text, timestamp } as AgentMessage, identity);
const phase =
item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
projected.push({
message,
responseItem: {
type: "message",
role,
content: [
{
type: role === "assistant" ? "output_text" : "input_text",
text,
},
],
...(role === "assistant" && phase ? { phase } : {}),
},
textBytes: Buffer.byteLength(text, "utf8"),
});
}
}
return projected;
}
function selectBoundedCodexHistoryTail(
projected: ProjectedCodexHistoryMessage[],
): ProjectedCodexHistoryMessage[] {
const selected: ProjectedCodexHistoryMessage[] = [];
let selectedBytes = 0;
for (let index = projected.length - 1; index >= 0; index -= 1) {
const candidate = projected[index];
if (!candidate) {
continue;
}
if (
selected.length >= CODEX_HISTORY_IMPORT_MAX_MESSAGES ||
selectedBytes + candidate.textBytes > CODEX_HISTORY_IMPORT_MAX_BYTES
) {
break;
}
selected.push(candidate);
selectedBytes += candidate.textBytes;
}
return selected.toReversed();
}
/** Projects one terminal Codex history prefix into transcript and Responses API items. */
export function projectBoundedCodexThreadHistory(params: {
thread: CodexThread;
throughTurnId: string | null;
importedAt: number;
modelProvider?: string | null;
}): BoundedCodexThreadHistoryProjection {
const projected = projectCodexThreadHistory({
thread: params.thread,
throughTurnId: params.throughTurnId,
importedAt: params.importedAt,
...(params.modelProvider ? { modelProvider: params.modelProvider } : {}),
});
const selected = selectBoundedCodexHistoryTail(projected);
return {
importedMessages: selected.length,
omittedMessages: projected.length - selected.length,
// Failed assistant fragments remain visible in operator transcripts, but
// injecting them would permanently replay incomplete model output.
responseItems: selected
.filter(
({ message }) =>
message.role !== "assistant" ||
(message.stopReason !== "aborted" && message.stopReason !== "error"),
)
.map(({ responseItem }) => responseItem),
transcriptMessages: selected.map(({ message }) => message),
};
}
@@ -924,6 +924,11 @@ describe("mirrorCodexAppServerTranscript", () => {
expect(raw).not.toContain('"idempotencyKey":"codex-app-server:thread-1:');
expect(first.userMessagesPresent).toHaveLength(1);
expect(second.userMessagesPresent).toHaveLength(1);
expect(first.userMessageReceipts).toHaveLength(1);
expect(second.userMessageReceipts).toHaveLength(1);
expect(second.userMessageReceipts[0]?.anchor.entryId).toBe(
first.userMessageReceipts[0]?.anchor.entryId,
);
expect(
(await readMirrorMessages(target)).filter((message) => message.role === "user"),
).toHaveLength(1);
@@ -1392,6 +1397,100 @@ describe("mirrorCodexAppServerTranscript", () => {
expect(JSON.stringify(mirrorOutcome.mirroredMessages)).not.toContain("sensitive answer");
});
it("returns the final mirrored row as the terminal anchor", async () => {
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-terminal-anchor-");
const assistantMessage = attachCodexMirrorIdentity(
makeAgentAssistantMessage({
content: [{ type: "toolCall", id: "call-1", name: "read", arguments: {} }],
timestamp: Date.now(),
}),
"turn-1:assistant",
);
const toolResultMessage = attachCodexMirrorIdentity(
castAgentMessage({
role: "toolResult",
toolCallId: "call-1",
toolName: "read",
content: [{ type: "toolResult", toolCallId: "call-1", content: "done" }],
timestamp: Date.now() + 1,
}),
"turn-1:tool-result:call-1",
);
const mirrorOutcome = await mirrorTranscriptBestEffort({
params: {
sessionId: target.sessionId,
sessionKey: target.sessionKey,
sessionTarget: target,
suppressNextUserMessagePersistence: true,
} as unknown as Parameters<typeof mirrorTranscriptBestEffort>[0]["params"],
result: {
messagesSnapshot: [assistantMessage, toolResultMessage],
} as Parameters<typeof mirrorTranscriptBestEffort>[0]["result"],
agentId: target.agentId,
sessionKey: target.sessionKey,
notifyUserMessagePersisted: () => undefined,
cwd: target.storePath,
threadId: "thread-1",
turnId: "turn-1",
});
const terminalEvent = (await readMirrorEvents(target)).find(
(event): event is { id: string; message: { role: string } } =>
Boolean(
event &&
typeof event === "object" &&
"id" in event &&
"message" in event &&
(event as { message?: { role?: unknown } }).message?.role === "toolResult",
),
);
expect(mirrorOutcome.assistantTranscriptOwned).toBe(true);
expect(mirrorOutcome.terminalAnchor?.entryId).toBe(terminalEvent?.id);
});
it("returns the user anchor for a turn without an assistant row", async () => {
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-user-terminal-");
const userMessage = attachCodexMirrorIdentity(
makeAgentUserMessage({
content: [{ type: "text", text: "run silently" }],
timestamp: Date.now(),
}),
"turn-1:prompt",
);
const mirrorOutcome = await mirrorTranscriptBestEffort({
params: {
sessionId: target.sessionId,
sessionKey: target.sessionKey,
sessionTarget: target,
suppressNextUserMessagePersistence: true,
} as unknown as Parameters<typeof mirrorTranscriptBestEffort>[0]["params"],
result: {
messagesSnapshot: [userMessage],
} as Parameters<typeof mirrorTranscriptBestEffort>[0]["result"],
agentId: target.agentId,
sessionKey: target.sessionKey,
notifyUserMessagePersisted: () => undefined,
cwd: target.storePath,
threadId: "thread-1",
turnId: "turn-1",
});
const terminalEvent = (await readMirrorEvents(target)).find(
(event): event is { id: string; message: { role: string } } =>
Boolean(
event &&
typeof event === "object" &&
"id" in event &&
"message" in event &&
(event as { message?: { role?: unknown } }).message?.role === "user",
),
);
expect(mirrorOutcome.assistantTranscriptOwned).toBe(false);
expect(mirrorOutcome.terminalAnchor?.entryId).toBe(terminalEvent?.id);
});
it("dedupes mirrored messages despite snapshot positional shifts", async () => {
const target = await createSqliteMirrorTarget("openclaw-codex-mirror-shift-");
const userMessage = attachCodexMirrorIdentity(
@@ -1,4 +1,3 @@
import { Buffer } from "node:buffer";
import { createHash } from "node:crypto";
import {
embeddedAgentLog,
@@ -9,15 +8,19 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { withCodexSessionTranscriptMirrorWriteLock } from "openclaw/plugin-sdk/codex-session-transcript-runtime";
import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm";
import {
publishSessionTranscriptUpdateByIdentity,
type TranscriptEntryAnchor,
type SessionTranscriptTargetParams,
type SessionTranscriptWriteLockParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { EmbeddedRunAttemptResult } from "./attempt-terminal.js";
import type { CodexThread, JsonValue } from "./protocol.js";
import type { CodexThread } from "./protocol.js";
import {
projectBoundedCodexThreadHistory,
type CodexThreadHistoryImportResult,
} from "./transcript-history-projection.js";
import {
attachCodexMirrorAttestation,
fingerprintCodexMirrorSourceMessage,
@@ -35,282 +38,26 @@ import {
} from "./user-prompt-message.js";
export { buildCodexUserPromptMessage };
export { projectBoundedCodexThreadHistory };
type MirroredAgentMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
type MirroredUserMessage = Extract<AgentMessage, { role: "user" }>;
type MirroredUserMessageReceipt = {
anchor: TranscriptEntryAnchor;
message: MirroredUserMessage;
};
type CodexAppServerTranscriptMirrorResult = {
assistantMirrorIdentitiesOwned: string[];
anchorsByMirrorIdentity: Map<string, TranscriptEntryAnchor>;
messagesPresent: MirroredAgentMessage[];
userMessagesPresent: MirroredUserMessage[];
userMessageReceipts: MirroredUserMessageReceipt[];
};
function isMirroredAgentMessage(message: AgentMessage): message is MirroredAgentMessage {
return message.role === "user" || message.role === "assistant" || message.role === "toolResult";
}
const CODEX_HISTORY_IMPORT_MAX_MESSAGES = 200;
const CODEX_HISTORY_IMPORT_MAX_BYTES = 512 * 1024;
const CODEX_HISTORY_IMPORT_MAX_MESSAGE_BYTES = 64 * 1024;
const CODEX_HISTORY_TRUNCATION_SUFFIX = "\n\n[Message truncated during Codex history import.]";
const CODEX_HISTORY_ASSISTANT_API = "openai-chatgpt-responses" as const;
const CODEX_HISTORY_ASSISTANT_PROVIDER = "openai";
const CODEX_HISTORY_ASSISTANT_MODEL = "native-history";
const CODEX_HISTORY_ZERO_USAGE: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
type CodexThreadHistoryImportResult = {
importedMessages: number;
omittedMessages: number;
};
type BoundedCodexThreadHistoryProjection = CodexThreadHistoryImportResult & {
responseItems: JsonValue[];
transcriptMessages: AgentMessage[];
};
type ProjectedCodexHistoryMessage = {
message: AgentMessage;
responseItem: JsonValue;
textBytes: number;
};
function isUtf8ContinuationByte(byte: number | undefined): boolean {
return byte !== undefined && (byte & 0xc0) === 0x80;
}
function truncateUtf8Prefix(value: string, maxBytes: number): string {
const bytes = Buffer.from(value);
if (bytes.byteLength <= maxBytes) {
return value;
}
let end = Math.max(0, maxBytes);
while (end > 0 && isUtf8ContinuationByte(bytes[end])) {
end -= 1;
}
return bytes.subarray(0, end).toString("utf8");
}
function normalizeImportedHistoryText(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const text = value.trim();
if (!text) {
return undefined;
}
if (Buffer.byteLength(text, "utf8") <= CODEX_HISTORY_IMPORT_MAX_MESSAGE_BYTES) {
return text;
}
const suffixBytes = Buffer.byteLength(CODEX_HISTORY_TRUNCATION_SUFFIX, "utf8");
const contentLimitBytes = Math.max(0, CODEX_HISTORY_IMPORT_MAX_MESSAGE_BYTES - suffixBytes);
return `${truncateUtf8Prefix(text, contentLimitBytes)}${CODEX_HISTORY_TRUNCATION_SUFFIX}`;
}
function projectCodexUserItemText(item: Record<string, unknown>): string | undefined {
if (!Array.isArray(item.content)) {
return undefined;
}
const parts: string[] = [];
for (const value of item.content) {
if (!value || typeof value !== "object" || Array.isArray(value)) {
continue;
}
const input = value as Record<string, unknown>;
if (input.type === "text") {
const text = normalizeImportedHistoryText(input.text);
if (text) {
parts.push(text);
}
continue;
}
if (input.type === "image" || input.type === "localImage") {
parts.push("[Image attachment]");
continue;
}
if (input.type === "audio" || input.type === "localAudio" || input.type === "local_audio") {
parts.push("[Audio attachment]");
}
if (input.type === "skill" || input.type === "mention") {
const name = normalizeOptionalString(input.name);
if (name) {
parts.push(`${input.type === "skill" ? "$" : "@"}${name}`);
}
}
}
return normalizeImportedHistoryText(parts.join("\n"));
}
function selectTurnsThroughBoundary(
thread: CodexThread,
throughTurnId: string | null,
): NonNullable<CodexThread["turns"]> {
if (throughTurnId === null) {
return [];
}
const turns = thread.turns ?? [];
const boundaryIndex = turns.findIndex((turn) => turn.id === throughTurnId);
if (boundaryIndex < 0) {
throw new Error(`Codex history boundary turn not found: ${throughTurnId}`);
}
const boundary = turns[boundaryIndex];
if (
boundary?.status !== "completed" &&
boundary?.status !== "interrupted" &&
boundary?.status !== "failed"
) {
throw new Error(`Codex history boundary turn is not terminal: ${throughTurnId}`);
}
return turns.slice(0, boundaryIndex + 1);
}
function projectCodexThreadHistory(params: {
thread: CodexThread;
throughTurnId: string | null;
importedAt: number;
modelProvider?: string;
}): ProjectedCodexHistoryMessage[] {
const projected: ProjectedCodexHistoryMessage[] = [];
const threadTimestamp =
typeof params.thread.createdAt === "number" && Number.isFinite(params.thread.createdAt)
? params.thread.createdAt * 1000
: params.importedAt;
let itemOffset = 0;
for (const turn of selectTurnsThroughBoundary(params.thread, params.throughTurnId)) {
for (const value of turn.items) {
const item = value as unknown as Record<string, unknown>;
const itemId = normalizeOptionalString(item.id);
const identity = `${turn.id}:${itemId ?? itemOffset}`;
const timestampSeconds =
item.type === "agentMessage"
? (turn.completedAt ?? turn.startedAt)
: (turn.startedAt ?? turn.completedAt);
const timestamp =
typeof timestampSeconds === "number" && Number.isFinite(timestampSeconds)
? timestampSeconds * 1000 + itemOffset
: threadTimestamp + itemOffset;
const text =
item.type === "userMessage"
? projectCodexUserItemText(item)
: item.type === "agentMessage"
? normalizeImportedHistoryText(item.text)
: undefined;
const role =
item.type === "userMessage"
? ("user" as const)
: item.type === "agentMessage"
? ("assistant" as const)
: undefined;
itemOffset += 1;
if (!text || !role) {
continue;
}
const message =
role === "assistant"
? attachCodexMirrorIdentity(
{
role,
content: [{ type: "text", text }],
api: CODEX_HISTORY_ASSISTANT_API,
provider:
normalizeOptionalString(params.modelProvider) ??
normalizeOptionalString(params.thread.modelProvider) ??
CODEX_HISTORY_ASSISTANT_PROVIDER,
model: CODEX_HISTORY_ASSISTANT_MODEL,
usage: CODEX_HISTORY_ZERO_USAGE,
stopReason:
turn.status === "interrupted"
? "aborted"
: turn.status === "failed"
? "error"
: "stop",
...(turn.status === "failed" && turn.error?.message
? { errorMessage: turn.error.message }
: {}),
timestamp,
} satisfies AssistantMessage,
identity,
)
: attachCodexMirrorIdentity({ role, content: text, timestamp } as AgentMessage, identity);
const phase =
item.phase === "commentary" || item.phase === "final_answer" ? item.phase : undefined;
projected.push({
message,
responseItem: {
type: "message",
role,
content: [
{
type: role === "assistant" ? "output_text" : "input_text",
text,
},
],
...(role === "assistant" && phase ? { phase } : {}),
},
textBytes: Buffer.byteLength(text, "utf8"),
});
}
}
return projected;
}
function selectBoundedCodexHistoryTail(
projected: ProjectedCodexHistoryMessage[],
): ProjectedCodexHistoryMessage[] {
const selected: ProjectedCodexHistoryMessage[] = [];
let selectedBytes = 0;
for (let index = projected.length - 1; index >= 0; index -= 1) {
const candidate = projected[index];
if (!candidate) {
continue;
}
if (
selected.length >= CODEX_HISTORY_IMPORT_MAX_MESSAGES ||
selectedBytes + candidate.textBytes > CODEX_HISTORY_IMPORT_MAX_BYTES
) {
break;
}
selected.push(candidate);
selectedBytes += candidate.textBytes;
}
return selected.toReversed();
}
/** Projects one terminal Codex history prefix into transcript and Responses API items. */
export function projectBoundedCodexThreadHistory(params: {
thread: CodexThread;
throughTurnId: string | null;
importedAt: number;
modelProvider?: string | null;
}): BoundedCodexThreadHistoryProjection {
const projected = projectCodexThreadHistory({
thread: params.thread,
throughTurnId: params.throughTurnId,
importedAt: params.importedAt,
...(params.modelProvider ? { modelProvider: params.modelProvider } : {}),
});
const selected = selectBoundedCodexHistoryTail(projected);
return {
importedMessages: selected.length,
omittedMessages: projected.length - selected.length,
// Failed assistant fragments remain visible in operator transcripts, but
// injecting them would permanently replay incomplete model output.
responseItems: selected
.filter(
({ message }) =>
message.role !== "assistant" ||
(message.stopReason !== "aborted" && message.stopReason !== "error"),
)
.map(({ responseItem }) => responseItem),
transcriptMessages: selected.map(({ message }) => message),
};
}
/** Imports a bounded, user-visible Codex history tail into a new OpenClaw transcript. */
export async function importCodexThreadHistoryToTranscript(params: {
thread: CodexThread;
@@ -350,7 +97,10 @@ export async function importCodexThreadHistoryToTranscript(params: {
async function mirrorBestEffort(params: {
params: EmbeddedRunAttemptParams;
agentId?: string;
notifyUserMessagePersisted: (message: Extract<AgentMessage, { role: "user" }>) => void;
notifyUserMessagePersisted: (
message: Extract<AgentMessage, { role: "user" }>,
anchor: TranscriptEntryAnchor,
) => void;
result: EmbeddedRunAttemptResult;
sessionKey?: string;
cwd: string;
@@ -359,6 +109,7 @@ async function mirrorBestEffort(params: {
}): Promise<{
assistantTranscriptOwned: boolean;
assistantTranscriptIdempotencyKey?: string;
terminalAnchor?: TranscriptEntryAnchor;
mirroredMessages: MirroredAgentMessage[];
}> {
try {
@@ -382,9 +133,9 @@ async function mirrorBestEffort(params: {
idempotencyScope: `codex-app-server:${params.threadId}`,
config: params.params.config,
});
for (const message of mirrorResult.userMessagesPresent) {
for (const receipt of mirrorResult.userMessageReceipts) {
try {
params.notifyUserMessagePersisted(message);
params.notifyUserMessagePersisted(receipt.message, receipt.anchor);
} catch (error) {
embeddedAgentLog.warn("failed to notify codex app-server user-message persistence", {
error: formatErrorMessage(error),
@@ -416,9 +167,18 @@ async function mirrorBestEffort(params: {
const assistantTranscriptIdempotencyKey = normalizeOptionalString(
(assistantTranscriptMessage as { idempotencyKey?: unknown } | undefined)?.idempotencyKey,
);
const terminalMessage = mirroredMessages.at(-1);
const terminalMirrorIdentity = terminalMessage
? readMirrorIdentity(terminalMessage)
: undefined;
const terminalAnchor =
(terminalMirrorIdentity
? mirrorResult.anchorsByMirrorIdentity.get(terminalMirrorIdentity)
: undefined) ?? params.params.userTurnTranscriptRecorder?.getAdmissionReceipt();
return {
assistantTranscriptOwned,
...(assistantTranscriptIdempotencyKey ? { assistantTranscriptIdempotencyKey } : {}),
...(terminalAnchor ? { terminalAnchor } : {}),
mirroredMessages,
};
} catch (error) {
@@ -458,14 +218,14 @@ async function resolveFinalCodexMirrorMessages(params: {
export function createCodexAppServerUserMessagePersistenceNotifier(
runParams: EmbeddedRunAttemptParams,
): (message: Extract<AgentMessage, { role: "user" }>) => void {
): (message: Extract<AgentMessage, { role: "user" }>, anchor: TranscriptEntryAnchor) => void {
let notified = false;
return (message) => {
return (message, anchor) => {
if (notified) {
return;
}
notified = true;
runParams.userTurnTranscriptRecorder?.markRuntimePersisted(message);
runParams.userTurnTranscriptRecorder?.markRuntimePersisted(message, anchor);
try {
runParams.onUserMessagePersisted?.(message);
} catch (error) {
@@ -479,7 +239,10 @@ export function createCodexAppServerUserMessagePersistenceNotifier(
export async function mirrorPromptAtTurnStartBestEffort(params: {
params: EmbeddedRunAttemptParams;
agentId?: string;
notifyUserMessagePersisted: (message: Extract<AgentMessage, { role: "user" }>) => void;
notifyUserMessagePersisted: (
message: Extract<AgentMessage, { role: "user" }>,
anchor: TranscriptEntryAnchor,
) => void;
sessionKey?: string;
cwd: string;
threadId: string;
@@ -511,8 +274,8 @@ export async function mirrorPromptAtTurnStartBestEffort(params: {
idempotencyScope: `codex-app-server:${params.threadId}`,
config: params.params.config,
});
for (const message of mirrorResult.userMessagesPresent) {
params.notifyUserMessagePersisted(message);
for (const receipt of mirrorResult.userMessageReceipts) {
params.notifyUserMessagePersisted(receipt.message, receipt.anchor);
}
})();
params.params.userTurnTranscriptRecorder?.markRuntimePersistencePending(mirrorPromise);
@@ -554,7 +317,13 @@ async function mirror(params: {
}): Promise<CodexAppServerTranscriptMirrorResult> {
const messages = params.messages.filter(isMirroredAgentMessage);
if (messages.length === 0) {
return { assistantMirrorIdentitiesOwned: [], messagesPresent: [], userMessagesPresent: [] };
return {
assistantMirrorIdentitiesOwned: [],
anchorsByMirrorIdentity: new Map(),
messagesPresent: [],
userMessageReceipts: [],
userMessagesPresent: [],
};
}
const candidates = messages.map((message) => {
@@ -586,7 +355,9 @@ async function mirror(params: {
messageSeq?: number;
}> = [];
const nextAssistantMirrorIdentitiesOwned = new Set<string>();
const nextAnchorsByMirrorIdentity = new Map<string, TranscriptEntryAnchor>();
const nextMessagesPresent: MirroredAgentMessage[] = [];
const nextUserMessageReceipts: MirroredUserMessageReceipt[] = [];
const nextUserMessagesPresent: MirroredUserMessage[] = [];
const mirrorFacts = await transcript.readMessageFacts({
idempotencyKeys: candidateIdempotencyKeys,
@@ -601,12 +372,22 @@ async function mirror(params: {
} as AgentMessage;
if (idempotencyKey && mirrorFacts.existingIdempotencyKeys.has(idempotencyKey)) {
const persistedMessage = mirrorFacts.messagesByIdempotencyKey.get(idempotencyKey);
const persistedAnchor = mirrorFacts.anchorsByIdempotencyKey.get(idempotencyKey);
if (persistedMessage && isMirroredAgentMessage(persistedMessage)) {
nextMessagesPresent.push(persistedMessage);
if (persistedMessage.role === "user") {
nextUserMessagesPresent.push(persistedMessage);
if (persistedAnchor) {
nextUserMessageReceipts.push({
anchor: persistedAnchor,
message: persistedMessage,
});
}
}
}
if (persistedAnchor) {
nextAnchorsByMirrorIdentity.set(dedupeIdentity, persistedAnchor);
}
if (message.role === "assistant") {
nextAssistantMirrorIdentitiesOwned.add(dedupeIdentity);
}
@@ -669,8 +450,15 @@ async function mirror(params: {
if (message.role === "assistant") {
nextAssistantMirrorIdentitiesOwned.add(dedupeIdentity);
}
if (appendedMessage.role === "user") {
if (appended.anchor) {
nextAnchorsByMirrorIdentity.set(dedupeIdentity, appended.anchor);
}
if (appendedMessage.role === "user" && appended.anchor) {
nextUserMessagesPresent.push(appendedMessage);
nextUserMessageReceipts.push({
anchor: appended.anchor,
message: appendedMessage,
});
}
if (appended.appended) {
nextAppendedUpdates.push({
@@ -681,18 +469,29 @@ async function mirror(params: {
}
if (idempotencyKey) {
mirrorFacts.existingIdempotencyKeys.add(idempotencyKey);
if (appended.anchor) {
mirrorFacts.anchorsByIdempotencyKey.set(idempotencyKey, appended.anchor);
}
}
}
return {
appendedUpdates: nextAppendedUpdates,
assistantMirrorIdentitiesOwned: [...nextAssistantMirrorIdentitiesOwned],
anchorsByMirrorIdentity: nextAnchorsByMirrorIdentity,
messagesPresent: nextMessagesPresent,
userMessageReceipts: nextUserMessageReceipts,
userMessagesPresent: nextUserMessagesPresent,
};
},
);
const { appendedUpdates, assistantMirrorIdentitiesOwned, messagesPresent, userMessagesPresent } =
mirrorBatch;
const {
appendedUpdates,
assistantMirrorIdentitiesOwned,
anchorsByMirrorIdentity,
messagesPresent,
userMessageReceipts,
userMessagesPresent,
} = mirrorBatch;
for (const update of appendedUpdates) {
try {
@@ -715,7 +514,13 @@ async function mirror(params: {
}
}
return { assistantMirrorIdentitiesOwned, messagesPresent, userMessagesPresent };
return {
assistantMirrorIdentitiesOwned,
anchorsByMirrorIdentity,
messagesPresent,
userMessageReceipts,
userMessagesPresent,
};
}
export const codexTranscriptMirrorRuntime = { mirror, mirrorBestEffort };
@@ -9,7 +9,10 @@ import {
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime";
import {
readSessionTranscriptEvents,
type TranscriptEntryAnchor,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import {
castAgentMessage,
makeAgentAssistantMessage,
@@ -27,6 +30,7 @@ const transcriptRace = vi.hoisted(() => ({
competingMessage: undefined as unknown,
lookups: [] as Array<string | undefined>,
publish: vi.fn(),
userAnchor: undefined as TranscriptEntryAnchor | undefined,
}));
vi.mock("openclaw/plugin-sdk/session-transcript-runtime", async (importOriginal) => {
@@ -65,7 +69,13 @@ vi.mock("openclaw/plugin-sdk/codex-session-transcript-runtime", async (importOri
},
appendMessageWithMessageSequence: async (options) => {
transcriptRace.lookups.push(options.idempotencyLookup);
return await locked.appendMessageWithMessageSequence(options);
const result = await locked.appendMessageWithMessageSequence(options);
const appended = result.result;
const message = appended?.message as AgentMessage | undefined;
if (appended && message?.role === "user") {
transcriptRace.userAnchor = appended.anchor;
}
return result;
},
};
return await run(intercepted);
@@ -78,6 +88,7 @@ afterEach(() => {
transcriptRace.competingMessage = undefined;
transcriptRace.lookups.length = 0;
transcriptRace.publish.mockReset();
transcriptRace.userAnchor = undefined;
});
it("adopts a competing indexed user without duplicating writes or slowing assistant mirrors", async () => {
@@ -150,13 +161,19 @@ it("adopts a competing indexed user without duplicating writes or slowing assist
idempotencyScope: "codex-app-server:thread-1",
});
const messages = (await readSessionTranscriptEvents(target))
.filter((event): event is { type: "message"; message: AgentMessage } => {
const messageEvents = (await readSessionTranscriptEvents(target)).filter(
(event): event is { id: string; type: "message"; message: AgentMessage } => {
return Boolean(
event && typeof event === "object" && "type" in event && event.type === "message",
event &&
typeof event === "object" &&
"id" in event &&
typeof event.id === "string" &&
"type" in event &&
event.type === "message",
);
})
.map((event) => event.message);
},
);
const messages = messageEvents.map((event) => event.message);
expect(messages.filter((message) => message.role === "user")).toHaveLength(1);
expect(messages.filter((message) => message.role === "assistant")).toHaveLength(1);
@@ -171,6 +188,12 @@ it("adopts a competing indexed user without duplicating writes or slowing assist
}),
}),
]);
expect(result.userMessageReceipts).toHaveLength(1);
expect(result.userMessageReceipts[0]?.message).toBe(result.userMessagesPresent[0]);
expect(result.userMessageReceipts[0]?.anchor).toBe(transcriptRace.userAnchor);
expect(result.userMessageReceipts[0]?.anchor.entryId).toBe(
messageEvents.find((event) => event.message.role === "user")?.id,
);
expect(result.assistantMirrorIdentitiesOwned).toEqual(["turn-1:assistant"]);
expect(transcriptRace.lookups).toEqual(["scan", "scan"]);
expect(transcriptRace.publish).toHaveBeenCalledTimes(1);
+4
View File
@@ -32,6 +32,7 @@ export function createResult(
aborted?: boolean;
assistantTranscriptOwned?: boolean;
assistantTranscriptIdempotencyKey?: string;
contextEngineTerminalAnchor?: import("openclaw/plugin-sdk/session-transcript-runtime").TranscriptEntryAnchor;
assistantTexts?: string[];
codeModeEngaged?: boolean;
currentAttemptAssistant?: AssistantMessage;
@@ -104,6 +105,9 @@ export function createResult(
}
: {}),
...(state.sdkSessionId ? { sdkSessionId: state.sdkSessionId } : {}),
...(state.contextEngineTerminalAnchor
? { contextEngineTerminalAnchor: state.contextEngineTerminalAnchor }
: {}),
...(state.journalValidated !== undefined ? { journalValidated: state.journalValidated } : {}),
...(state.codeModeEngaged !== undefined ? { codeModeEngaged: state.codeModeEngaged } : {}),
assistantTexts: state.assistantTexts ?? [],
@@ -121,6 +121,7 @@ export async function completeCopilotAttempt(params: {
messagesSnapshot,
assistantTranscriptOwned: transcript?.assistantTranscriptOwned,
assistantTranscriptIdempotencyKey: transcript?.assistantTranscriptIdempotencyKey,
contextEngineTerminalAnchor: transcript?.terminalAnchor,
nativeReplayInvalid: transcript?.replayInvalid === true || nativeSessionHistoryUnvalidated,
now,
promptError,
@@ -0,0 +1,163 @@
import fs from "node:fs/promises";
import path from "node:path";
import type { SessionEvent } from "@github/copilot-sdk";
import type { AgentMessage } from "openclaw/plugin-sdk/agent-harness-runtime";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import type {
TranscriptEntryAnchor,
SessionTranscriptTargetParams,
TranscriptTurnAdmission,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { vi } from "vitest";
import { createAttemptTranscriptJournal } from "./attempt-transcript-journal.js";
import type { AttemptParamsLike } from "./attempt-types.js";
import { attachEventBridge, type SessionLike } from "./event-bridge.js";
const tempDirs: string[] = [];
export type FakeSession = SessionLike & {
emit: (event: SessionEvent) => void;
};
export function createFakeSession(): FakeSession {
const listeners = new Map<string, Array<(event: SessionEvent) => void>>();
return {
abort: vi.fn(async () => undefined),
disconnect: vi.fn(async () => undefined),
emit(sessionEvent) {
for (const listener of listeners.get(sessionEvent.type) ?? []) {
listener(sessionEvent);
}
},
on: vi.fn((eventType: string, handler: (event: SessionEvent) => void) => {
listeners.set(eventType, [...(listeners.get(eventType) ?? []), handler]);
}) as FakeSession["on"],
send: vi.fn(async () => "sdk-user"),
sendAndWait: vi.fn(async () => undefined),
sessionId: "sdk-session",
};
}
export function event(
type: string,
id: string,
data: Record<string, unknown>,
agentId?: string,
): SessionEvent {
return {
type,
id,
parentId: null,
timestamp: "2026-07-26T12:00:00.000Z",
data,
...(agentId ? { agentId } : {}),
} as SessionEvent;
}
export async function createFixture(
trigger?: string,
resultContentSourceByToolName?: ReadonlyMap<string, "network">,
) {
const tempDir = await fs.mkdtemp(
path.join(resolvePreferredOpenClawTmpDir(), "openclaw-copilot-journal-"),
);
tempDirs.push(tempDir);
const target: SessionTranscriptTargetParams = {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
storePath: path.join(tempDir, "sessions.json"),
};
const userMessage: Extract<AgentMessage, { role: "user" }> = {
role: "user",
content: "inspect both files",
timestamp: 1,
};
let blocked = false;
let persisted = false;
let admissionReceipt: TranscriptTurnAdmission | undefined;
const recorder = {
message: userMessage,
resolveMessage: vi.fn(async () => userMessage),
markRuntimePersistencePending: vi.fn(),
markRuntimePersisted: vi.fn(
(
_message?: Extract<AgentMessage, { role: "user" }>,
anchor?: TranscriptEntryAnchor | TranscriptTurnAdmission,
) => {
persisted = true;
admissionReceipt =
anchor && "logicalTurnId" in anchor
? anchor
: anchor
? { ...anchor, logicalTurnId: "logical-turn-1", role: "user" }
: undefined;
},
),
markBlocked: vi.fn(() => {
blocked = true;
}),
hasPersisted: () => persisted,
isBlocked: () => blocked,
hasRuntimePersistencePending: () => false,
getAdmissionReceipt: () => admissionReceipt,
waitForRuntimePersistence: vi.fn(async () => undefined),
persistApproved: vi.fn(async () => undefined),
persistBlocked: vi.fn(async () => undefined),
persistFallback: vi.fn(async () => undefined),
} satisfies NonNullable<AttemptParamsLike["userTurnTranscriptRecorder"]>;
const attempt = {
agentId: "main",
prompt: "inspect both files",
runId: "run-1",
sessionId: target.sessionId,
sessionKey: target.sessionKey,
sessionTarget: target,
timeoutMs: 1000,
trigger,
userTurnTranscriptRecorder: recorder,
} as unknown as AttemptParamsLike;
await upsertSessionEntry({
agentId: "main",
entry: { sessionId: target.sessionId, updatedAt: 1 },
sessionKey: target.sessionKey,
storePath: target.storePath,
});
const session = createFakeSession();
const journal = createAttemptTranscriptJournal({
abortSession: () => session.abort(),
attempt,
messages: [],
sdkSessionId: "sdk-session",
});
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session",
isAborted: () => false,
transcriptProjection: {
journal,
modelRef: { api: "openai-responses", id: "gpt-5", provider: "github-copilot" },
now: () => 2,
...(resultContentSourceByToolName ? { resultContentSourceByToolName } : {}),
},
});
return { attempt, bridge, journal, recorder, session, target, tempDir };
}
export function transcriptMessages(events: unknown[]) {
return events.flatMap((entry) => {
if (!entry || typeof entry !== "object" || (entry as { type?: unknown }).type !== "message") {
return [];
}
const record = entry as {
id: string;
parentId: string | null;
message: AgentMessage & { display?: boolean; idempotencyKey?: string };
};
return [record];
});
}
export async function cleanupAttemptTranscriptJournalFixtures(): Promise<void> {
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })));
}
@@ -1,5 +1,4 @@
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { DatabaseSync } from "node:sqlite";
import type { SessionEvent } from "@github/copilot-sdk";
@@ -9,149 +8,23 @@ import {
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { upsertSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import {
readSessionTranscriptEvents,
type SessionTranscriptTargetParams,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import { readSessionTranscriptEvents } from "openclaw/plugin-sdk/session-transcript-runtime";
import { afterEach, describe, expect, it, vi } from "vitest";
import { createAttemptTranscriptJournal } from "./attempt-transcript-journal.js";
import type { AttemptParamsLike } from "./attempt-types.js";
import { attachEventBridge, type SessionLike } from "./event-bridge.js";
const tempDirs: string[] = [];
type FakeSession = SessionLike & {
emit: (event: SessionEvent) => void;
};
function createFakeSession(): FakeSession {
const listeners = new Map<string, Array<(event: SessionEvent) => void>>();
return {
abort: vi.fn(async () => undefined),
disconnect: vi.fn(async () => undefined),
emit(sessionEvent) {
for (const listener of listeners.get(sessionEvent.type) ?? []) {
listener(sessionEvent);
}
},
on: vi.fn((eventType: string, handler: (event: SessionEvent) => void) => {
listeners.set(eventType, [...(listeners.get(eventType) ?? []), handler]);
}) as FakeSession["on"],
send: vi.fn(async () => "sdk-user"),
sendAndWait: vi.fn(async () => undefined),
sessionId: "sdk-session",
};
}
function event(
type: string,
id: string,
data: Record<string, unknown>,
agentId?: string,
): SessionEvent {
return {
type,
id,
parentId: null,
timestamp: "2026-07-26T12:00:00.000Z",
data,
...(agentId ? { agentId } : {}),
} as SessionEvent;
}
async function createFixture(
trigger?: string,
resultContentSourceByToolName?: ReadonlyMap<string, "network">,
) {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-copilot-journal-"));
tempDirs.push(tempDir);
const target: SessionTranscriptTargetParams = {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
storePath: path.join(tempDir, "sessions.json"),
};
const userMessage: Extract<AgentMessage, { role: "user" }> = {
role: "user",
content: "inspect both files",
timestamp: 1,
};
let blocked = false;
let persisted = false;
const recorder = {
message: userMessage,
resolveMessage: vi.fn(async () => userMessage),
markRuntimePersistencePending: vi.fn(),
markRuntimePersisted: vi.fn(() => {
persisted = true;
}),
markBlocked: vi.fn(() => {
blocked = true;
}),
hasPersisted: () => persisted,
isBlocked: () => blocked,
hasRuntimePersistencePending: () => false,
waitForRuntimePersistence: vi.fn(async () => undefined),
persistApproved: vi.fn(async () => undefined),
persistBlocked: vi.fn(async () => undefined),
persistFallback: vi.fn(async () => undefined),
} satisfies NonNullable<AttemptParamsLike["userTurnTranscriptRecorder"]>;
const attempt = {
agentId: "main",
prompt: "inspect both files",
runId: "run-1",
sessionId: target.sessionId,
sessionKey: target.sessionKey,
sessionTarget: target,
timeoutMs: 1000,
trigger,
userTurnTranscriptRecorder: recorder,
} as unknown as AttemptParamsLike;
await upsertSessionEntry({
agentId: "main",
entry: { sessionId: target.sessionId, updatedAt: 1 },
sessionKey: target.sessionKey,
storePath: target.storePath,
});
const session = createFakeSession();
const journal = createAttemptTranscriptJournal({
abortSession: () => session.abort(),
attempt,
messages: [],
sdkSessionId: "sdk-session",
});
const bridge = attachEventBridge(session, {
getSdkSessionId: () => "sdk-session",
isAborted: () => false,
transcriptProjection: {
journal,
modelRef: { api: "openai-responses", id: "gpt-5", provider: "github-copilot" },
now: () => 2,
...(resultContentSourceByToolName ? { resultContentSourceByToolName } : {}),
},
});
return { attempt, bridge, journal, recorder, session, target, tempDir };
}
function transcriptMessages(events: unknown[]) {
return events.flatMap((entry) => {
if (!entry || typeof entry !== "object" || (entry as { type?: unknown }).type !== "message") {
return [];
}
const record = entry as {
id: string;
parentId: string | null;
message: AgentMessage & { display?: boolean; idempotencyKey?: string };
};
return [record];
});
}
import {
cleanupAttemptTranscriptJournalFixtures,
createFakeSession,
createFixture,
event,
type FakeSession,
transcriptMessages,
} from "./attempt-transcript-journal.test-helpers.js";
import { attachEventBridge } from "./event-bridge.js";
afterEach(async () => {
resetGlobalHookRunner();
vi.restoreAllMocks();
await Promise.all(tempDirs.splice(0).map((dir) => fs.rm(dir, { force: true, recursive: true })));
await cleanupAttemptTranscriptJournalFixtures();
});
describe("Copilot attempt transcript journal", () => {
@@ -230,6 +103,21 @@ describe("Copilot attempt transcript journal", () => {
]);
});
it("publishes the exact storage anchor for the recorder admission", async () => {
const { journal, recorder } = await createFixture();
await journal.persistInitialUser();
const anchor = recorder.markRuntimePersisted.mock.calls[0]?.[1];
expect(anchor).toBeDefined();
expect(journal.snapshot().terminalAnchor).toEqual(anchor);
expect(recorder.getAdmissionReceipt()).toEqual({
...anchor,
logicalTurnId: "logical-turn-1",
role: "user",
});
});
it("removes the originally staged user when its resolved replacement is blocked", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
@@ -687,6 +575,7 @@ describe("Copilot attempt transcript journal", () => {
stopReason: "toolUse",
});
expect(rows[2]?.message).toMatchObject({ toolCallId: "call-a", toolName: "read" });
expect(journal.snapshot().terminalAnchor?.entryId).toBe(rows[2]?.id);
expect(
bridge.buildAssistantMessage({
modelRef: { api: "openai-responses", id: "gpt-5", provider: "github-copilot" },
@@ -10,12 +10,18 @@ import {
publishSessionTranscriptUpdateByIdentity,
readVisibleSessionTranscriptMessageEntries,
type SessionTranscriptTargetParams,
type TranscriptEntryAnchor,
} from "openclaw/plugin-sdk/session-transcript-runtime";
import type { AttemptParamsLike } from "./attempt-types.js";
type TranscriptMessage = Extract<AgentMessage, { role: "user" | "assistant" | "toolResult" }>;
type AppendResult =
| { appended: boolean; message: TranscriptMessage; messageId: string }
| {
anchor: TranscriptEntryAnchor;
appended: boolean;
message: TranscriptMessage;
messageId: string;
}
| undefined;
type PendingWrite = { eventId?: string; message: TranscriptMessage };
type ToolGroup = {
@@ -128,6 +134,7 @@ export function createAttemptTranscriptJournal(params: {
let latestAssistantKey: string | undefined;
let assistantTranscriptOwned = false;
let assistantTranscriptIdempotencyKey: string | undefined;
let terminalAnchor: TranscriptEntryAnchor | undefined;
const captureFailure = (error: unknown) => {
if (firstFailure) {
@@ -310,10 +317,11 @@ export function createAttemptTranscriptJournal(params: {
}
return result.appended;
};
const ownAssistant = (key: string, persisted: boolean) => {
const ownAssistant = (key: string, persisted: boolean, anchor?: TranscriptEntryAnchor) => {
if (latestAssistantKey === key) {
assistantTranscriptOwned = true;
assistantTranscriptIdempotencyKey = persisted ? key : undefined;
terminalAnchor = persisted ? anchor : undefined;
}
};
@@ -366,6 +374,7 @@ export function createAttemptTranscriptJournal(params: {
latestAssistantKey = undefined;
assistantTranscriptOwned = false;
assistantTranscriptIdempotencyKey = undefined;
terminalAnchor = undefined;
},
async persistInitialUser() {
const recorder = params.attempt.userTurnTranscriptRecorder;
@@ -398,7 +407,8 @@ export function createAttemptTranscriptJournal(params: {
const persisted = outcome.message as Extract<AgentMessage, { role: "user" }>;
accept(outcome);
persistedInitialUser = persisted;
recorder.markRuntimePersisted(persisted);
terminalAnchor = outcome.anchor;
recorder.markRuntimePersisted(persisted, outcome.anchor);
params.attempt.onUserMessagePersisted?.(persisted);
await publish(outcome.appended);
})();
@@ -463,6 +473,7 @@ export function createAttemptTranscriptJournal(params: {
latestAssistantKey = key;
assistantTranscriptOwned = false;
assistantTranscriptIdempotencyKey = undefined;
terminalAnchor = undefined;
schedule(async () => {
if (pendingTools) {
throw new Error("Copilot emitted an assistant message before tool results settled");
@@ -484,7 +495,7 @@ export function createAttemptTranscriptJournal(params: {
if (!outcome) {
replayInvalid = true;
}
ownAssistant(key, Boolean(outcome));
ownAssistant(key, Boolean(outcome), outcome?.anchor);
await publish(accept(outcome));
});
},
@@ -523,7 +534,7 @@ export function createAttemptTranscriptJournal(params: {
const didAppend = accept(result as AppendResult);
appended ||= didAppend;
}
ownAssistant(group.assistantKey, true);
ownAssistant(group.assistantKey, true, results.at(-1)?.anchor);
}
pendingTools = undefined;
const deferredReceipts: PersistenceReceipt[] = [];
@@ -558,6 +569,7 @@ export function createAttemptTranscriptJournal(params: {
snapshot: () => ({
assistantTranscriptOwned,
assistantTranscriptIdempotencyKey,
terminalAnchor,
initialSdkUserValidated,
messagesSnapshot: [...messagesSnapshot],
replayInvalid,
+2
View File
@@ -8,6 +8,7 @@ import {
resolveSandboxContext as defaultResolveSandboxContext,
runAgentEndSideEffects,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import type { TranscriptEntryAnchor } from "openclaw/plugin-sdk/session-transcript-runtime";
import type { OnAssistantDeltaPayload } from "./event-bridge.js";
import type { CopilotHooksConfig } from "./hooks-bridge.js";
import type { CopilotPermissionPolicy } from "./permission-bridge.js";
@@ -29,6 +30,7 @@ export type AgentHarnessAttemptResult = Extract<
>;
type AttemptTerminal = AgentHarnessAttemptResult["terminal"];
export type AttemptResultWithSdkSessionId = AgentHarnessAttemptResult & {
contextEngineTerminalAnchor?: TranscriptEntryAnchor;
journalValidated?: boolean;
sdkSessionId?: string;
};
+1
View File
@@ -402,6 +402,7 @@ function makeUserTurnRecorder(
hasPersisted: () => persisted,
isBlocked: () => blocked,
hasRuntimePersistencePending: () => false,
getAdmissionReceipt: () => undefined,
waitForRuntimePersistence: vi.fn(async () => undefined),
persistApproved: vi.fn(async () => undefined),
persistBlocked: vi.fn(async () => undefined),
@@ -8,6 +8,7 @@ import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import { resolveChunkMode, resolveTextChunkLimit } from "openclaw/plugin-sdk/reply-chunking";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import type { createSubsystemLogger } from "openclaw/plugin-sdk/runtime-env";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { resolveDiscordMaxLinesPerMessage } from "../accounts.js";
@@ -33,6 +34,11 @@ type NativeCommandEffectiveRoute = {
sessionKey: string;
};
type DispatchDiscordNativeAgentReplyResult = {
dispatched: boolean;
hiddenFinalReply?: ReplyPayload;
};
export async function dispatchDiscordNativeAgentReply(params: {
cfg: OpenClawConfig;
discordConfig: DiscordConfig;
@@ -46,11 +52,12 @@ export async function dispatchDiscordNativeAgentReply(params: {
responseEphemeral?: boolean;
suppressReplies?: boolean;
log: ReturnType<typeof createSubsystemLogger>;
}): Promise<void> {
}): Promise<DispatchDiscordNativeAgentReplyResult> {
const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(params.discordConfig);
let didReply = false;
let finalReplyOutcome: "accepted" | "failed" | "suppressed" | undefined;
let hiddenFinalReply: ReplyPayload | undefined;
const turnResult = await nativeCommandRuntime.dispatchChannelInboundTurn({
cfg: params.cfg,
channel: "discord",
@@ -92,7 +99,16 @@ export async function dispatchDiscordNativeAgentReply(params: {
suppression: { reason: "no_visible_result" as const },
};
},
onDelivered: (_payload, info, result) => {
onDelivered: (payload, info, result) => {
// Hidden picker dispatch reuses only a real core final suppressed by this adapter.
if (
params.suppressReplies &&
info.kind === "final" &&
result?.suppression?.reason === "no_visible_result" &&
payload.text?.trim()
) {
hiddenFinalReply = payload;
}
// A failed final outweighs later suppression until Discord accepts a final.
if (
info.kind === "final" &&
@@ -127,16 +143,25 @@ export async function dispatchDiscordNativeAgentReply(params: {
typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : undefined,
},
});
const deliberateSilentTerminalReply =
turnResult.dispatched && turnResult.dispatchResult.deliberateSilentTerminalReply === true;
const dispatchResult = {
dispatched: turnResult.dispatched,
...(hiddenFinalReply ? { hiddenFinalReply } : {}),
};
if (!didReply && (params.suppressReplies || finalReplyOutcome === "suppressed")) {
if (
!didReply &&
(params.suppressReplies || finalReplyOutcome === "suppressed" || deliberateSilentTerminalReply)
) {
await settleDiscordInteractionWithoutVisibleReply(params.interaction);
return;
return dispatchResult;
}
if (
didReply ||
(turnResult.dispatched && hasVisibleInboundReplyDispatch(turnResult.dispatchResult))
) {
return;
return dispatchResult;
}
await safeDiscordInteractionCall("interaction empty fallback", async () => {
@@ -150,4 +175,5 @@ export async function dispatchDiscordNativeAgentReply(params: {
}
await params.interaction.reply(payload);
});
return dispatchResult;
}
@@ -1,6 +1,7 @@
// Discord plugin module implements native command dispatch behavior.
import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-dispatch-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import type {
ButtonInteraction,
@@ -29,6 +30,7 @@ type DispatchDiscordCommandInteractionParams = {
export type DispatchDiscordCommandInteractionResult = {
accepted: boolean;
effectiveRoute?: ResolvedAgentRoute;
hiddenFinalReply?: ReplyPayload;
};
export type DispatchDiscordCommandInteraction = (
@@ -1,15 +1,7 @@
// Discord plugin module implements native command model picker apply behavior.
import { randomUUID } from "node:crypto";
import { resolveAgentDir } from "openclaw/plugin-sdk/agent-runtime";
import type { ChatCommandDefinition, CommandArgs } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
applyModelOverrideWithAuthProfileCompatibility,
ModelSelectionLockedError,
} from "openclaw/plugin-sdk/model-session-runtime";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { patchSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import { withTimeout } from "openclaw/plugin-sdk/text-utility-runtime";
import type { ButtonInteraction, StringSelectMenuInteraction } from "../internal/discord.js";
import {
@@ -34,59 +26,12 @@ type DiscordModelPickerApplyResult =
| { status: "timeout"; noticeMessage: string }
| { status: "failed"; noticeMessage: string };
async function persistDiscordModelPickerOverride(params: {
cfg: OpenClawConfig;
route: ResolvedAgentRoute;
provider: string;
model: string;
isDefault: boolean;
defaultProvider: string;
runtime?: string;
}): Promise<boolean> {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
let persisted = false;
await patchSessionEntry({
storePath,
sessionKey: params.route.sessionKey,
fallbackEntry: {
sessionId: randomUUID(),
updatedAt: Date.now(),
},
replaceEntry: true,
update: (entry) => {
const currentProvider =
entry.providerOverride?.trim() || entry.modelProvider?.trim() || params.defaultProvider;
persisted =
applyModelOverrideWithAuthProfileCompatibility({
cfg: params.cfg,
agentDir: resolveAgentDir(params.cfg, params.route.agentId),
entry,
currentProvider,
selection: {
provider: params.provider,
model: params.model,
isDefault: params.isDefault,
},
markLiveSwitchPending: true,
}).updated || persisted;
const runtime = params.runtime?.trim();
if (runtime && runtime !== "auto" && runtime !== "default") {
if (entry.agentRuntimeOverride !== runtime) {
entry.agentRuntimeOverride = runtime;
delete entry.agentHarnessId;
persisted = true;
}
} else if (runtime && entry.agentRuntimeOverride) {
delete entry.agentRuntimeOverride;
delete entry.agentHarnessId;
persisted = true;
}
return entry;
},
});
return persisted;
function normalizeExpectedRuntime(value: string | undefined): string | undefined {
const runtime = value?.trim();
if (!runtime) {
return undefined;
}
return runtime === "auto" || runtime === "default" ? "auto" : runtime;
}
export async function applyDiscordModelPickerSelection(params: {
@@ -100,14 +45,11 @@ export async function applyDiscordModelPickerSelection(params: {
threadBindings: ThreadBindingManager;
route: ResolvedAgentRoute;
resolvedModelRef: string;
selectedProvider: string;
selectedModel: string;
selectedRuntime?: string;
defaultProvider: string;
defaultModel: string;
preferenceScope: DiscordModelPickerPreferenceScope;
settleMs: number;
resolveCurrentModel: (route: ResolvedAgentRoute) => string;
resolveCurrentRuntime: (route: ResolvedAgentRoute) => string;
}): Promise<DiscordModelPickerApplyResult> {
try {
const dispatchResult = await withTimeout(
@@ -132,80 +74,28 @@ export async function applyDiscordModelPickerSelection(params: {
noticeMessage: `❌ Failed to apply ${params.resolvedModelRef}. Try /model ${params.resolvedModelRef} directly.`,
};
}
const fallbackRoute = dispatchResult.effectiveRoute ?? params.route;
const hiddenFinalReply = dispatchResult.hiddenFinalReply;
const effectiveRoute = dispatchResult.effectiveRoute ?? params.route;
if (params.settleMs > 0) {
await new Promise((resolve) => {
setTimeout(resolve, params.settleMs);
});
}
let effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
let persisted = effectiveModelRef === params.resolvedModelRef;
if (params.selectedRuntime?.trim()) {
await persistDiscordModelPickerOverride({
cfg: params.cfg,
route: fallbackRoute,
provider: params.selectedProvider,
model: params.selectedModel,
defaultProvider: params.defaultProvider,
isDefault:
params.selectedProvider === params.defaultProvider &&
params.selectedModel === params.defaultModel,
runtime: params.selectedRuntime,
});
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
persisted = effectiveModelRef === params.resolvedModelRef;
const effectiveModelRef = params.resolveCurrentModel(effectiveRoute);
const effectiveRuntime = params.resolveCurrentRuntime(effectiveRoute);
const currentSelection = `Current selection: ${effectiveModelRef} with runtime ${effectiveRuntime}.`;
if (hiddenFinalReply?.isError) {
return {
status: "rejected",
noticeMessage: `${hiddenFinalReply.text?.trim()}\n${currentSelection}`,
};
}
if (!persisted) {
logVerbose(
`discord: model picker override mismatch — expected ${params.resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}; attempting direct session override persist`,
);
try {
const directlyPersisted = await persistDiscordModelPickerOverride({
cfg: params.cfg,
route: fallbackRoute,
provider: params.selectedProvider,
model: params.selectedModel,
defaultProvider: params.defaultProvider,
isDefault:
params.selectedProvider === params.defaultProvider &&
params.selectedModel === params.defaultModel,
runtime: params.selectedRuntime,
});
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
effectiveModelRef = params.resolveCurrentModel(fallbackRoute);
persisted = effectiveModelRef === params.resolvedModelRef;
if (!persisted) {
logVerbose(
`discord: direct session override persist failed — expected ${params.resolvedModelRef} but read ${effectiveModelRef} from session key ${fallbackRoute.sessionKey}`,
);
} else if (!directlyPersisted) {
logVerbose(
`discord: direct session override persist became a no-op because ${params.resolvedModelRef} was already present on re-read for session key ${fallbackRoute.sessionKey}`,
);
}
} catch (error) {
if (error instanceof ModelSelectionLockedError) {
return {
status: "rejected",
noticeMessage: `${error.message}`,
};
}
const message = error instanceof Error ? error.message : String(error);
logVerbose(
`discord: direct session override persist threw for session key ${fallbackRoute.sessionKey}: ${message}`,
);
}
}
if (persisted) {
const expectedRuntime = normalizeExpectedRuntime(params.selectedRuntime);
const verified =
effectiveModelRef === params.resolvedModelRef &&
(expectedRuntime === undefined || effectiveRuntime === expectedRuntime);
if (verified) {
await recordDiscordModelPickerRecentModel({
scope: params.preferenceScope,
modelRef: params.resolvedModelRef,
@@ -213,24 +103,19 @@ export async function applyDiscordModelPickerSelection(params: {
}).catch(() => undefined);
}
return persisted
return verified
? {
status: "success",
effectiveModelRef,
noticeMessage: `✅ Model set to ${params.resolvedModelRef}.`,
noticeMessage:
hiddenFinalReply?.text?.trim() || `✅ Model set to ${params.resolvedModelRef}.`,
}
: {
status: "mismatch",
effectiveModelRef,
noticeMessage: `⚠️ Tried to set ${params.resolvedModelRef}, but current model is ${effectiveModelRef}.`,
noticeMessage: `⚠️ Tried to set ${params.resolvedModelRef}${expectedRuntime ? ` with runtime ${expectedRuntime}` : ""}, but current selection is ${effectiveModelRef} with runtime ${effectiveRuntime}.`,
};
} catch (error) {
if (error instanceof ModelSelectionLockedError) {
return {
status: "rejected",
noticeMessage: `${error.message}`,
};
}
if (error instanceof Error && error.message === "timeout") {
return {
status: "timeout",
@@ -176,6 +176,7 @@ function resolveSubmittedModelRef(params: {
function buildDiscordModelPickerSelectionCommand(params: {
modelRef: string;
runtime?: string;
}): { command: ChatCommandDefinition; args: CommandArgs; prompt: string } | null {
const commandDefinition =
findCommandByNativeName("model", "discord") ??
@@ -187,7 +188,7 @@ function buildDiscordModelPickerSelectionCommand(params: {
values: {
model: params.modelRef,
},
raw: params.modelRef,
raw: params.runtime ? `${params.modelRef} --runtime ${params.runtime}` : params.modelRef,
};
return {
command: commandDefinition,
@@ -204,7 +205,8 @@ function listDiscordModelPickerProviderModels(
if (!modelSet) {
return [];
}
return [...modelSet].toSorted();
// Legacy index callbacks depend on JavaScript's original UTF-16 code-unit ordering.
return [...modelSet].toSorted((left, right) => (left < right ? -1 : left > right ? 1 : 0));
}
function resolveDiscordModelPickerModelRefByToken(
@@ -285,21 +287,13 @@ function resolveDiscordModelPickerSubmissionRuntime(params: {
data: Awaited<ReturnType<typeof loadDiscordModelPickerData>>;
provider: string;
parsedRuntime?: string;
currentRuntime?: string;
}): string | undefined {
return (
resolveDiscordModelPickerRuntimeForProvider({
data: params.data,
provider: params.provider,
runtime: params.parsedRuntime,
allowResetRuntime: true,
}) ??
resolveDiscordModelPickerRuntimeForProvider({
data: params.data,
provider: params.provider,
runtime: params.currentRuntime,
})
);
return resolveDiscordModelPickerRuntimeForProvider({
data: params.data,
provider: params.provider,
runtime: params.parsedRuntime,
allowResetRuntime: true,
});
}
async function handleDiscordModelPickerInteraction(params: {
@@ -602,10 +596,10 @@ async function handleDiscordModelPickerInteraction(params: {
parsed,
selectedProvider: parsedModelRef.provider,
}),
currentRuntime,
});
const selectionCommand = buildDiscordModelPickerSelectionCommand({
modelRef: resolvedModelRef,
runtime: selectedRuntime,
});
if (!selectionCommand) {
await showNotice("Sorry, /model is unavailable right now.");
@@ -628,11 +622,7 @@ async function handleDiscordModelPickerInteraction(params: {
threadBindings: ctx.threadBindings,
route,
resolvedModelRef,
selectedProvider: parsedModelRef.provider,
selectedModel: parsedModelRef.model,
selectedRuntime,
defaultProvider: pickerData.resolvedDefault.provider,
defaultModel: pickerData.resolvedDefault.model,
preferenceScope,
settleMs: ctx.postApplySettleMs ?? 250,
resolveCurrentModel: (currentRoute) =>
@@ -641,6 +631,11 @@ async function handleDiscordModelPickerInteraction(params: {
route: currentRoute,
data: pickerData,
}),
resolveCurrentRuntime: (currentRoute) =>
resolveDiscordModelPickerCurrentRuntime({
cfg,
route: currentRoute,
}),
});
await params.safeInteractionCall("model picker follow-up", () =>
@@ -10,14 +10,8 @@ import type {
} from "openclaw/plugin-sdk/command-auth-native";
import type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import type { ResolvedAgentRoute } from "openclaw/plugin-sdk/routing";
import * as runtimeConfigSnapshotModule from "openclaw/plugin-sdk/runtime-config-snapshot";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import {
getSessionEntry,
listSessionEntries,
resolveStorePath,
upsertSessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
import * as commandTextModule from "openclaw/plugin-sdk/text-utility-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { defineThrowingDiscordChannelGetter } from "../test-support/partial-channel.js";
@@ -26,6 +20,7 @@ import * as modelPickerPreferencesModule from "./model-picker-preferences.js";
import * as modelPickerModule from "./model-picker.state.js";
import { createModelsProviderData as createBaseModelsProviderData } from "./model-picker.test-utils.js";
import type { DispatchDiscordCommandInteraction } from "./native-command-dispatch.js";
import { applyDiscordModelPickerSelection } from "./native-command-model-picker-apply.js";
import {
createDiscordModelPickerFallbackButton,
createDiscordModelPickerFallbackSelect,
@@ -60,6 +55,19 @@ type MockInteraction = {
let tempDir: string;
function createResolvedAgentRoute(overrides: Partial<ResolvedAgentRoute> = {}): ResolvedAgentRoute {
return {
agentId: "main",
channel: "discord",
accountId: "default",
sessionKey: "agent:main:discord:dm:owner",
mainSessionKey: "agent:main:main",
lastRoutePolicy: "session",
matchedBy: "default",
...overrides,
};
}
function createModelsProviderData(entries: Record<string, string[]>): ModelsProviderData {
return createBaseModelsProviderData(entries, { defaultProviderOrder: "sorted" });
}
@@ -257,6 +265,9 @@ function expectDispatchedModelSelection(params: {
: `/model ${params.model}`,
);
expect(dispatchCall?.commandArgs?.values?.model).toBe(params.model);
expect(dispatchCall?.commandArgs?.raw).toBe(
params.runtime ? `${params.model} --runtime ${params.runtime}` : params.model,
);
}
function createBoundThreadBindingManager(params: {
@@ -467,11 +478,193 @@ describe("Discord model picker interactions", () => {
| Parameters<DispatchDiscordCommandInteraction>[0]
| undefined;
expect(dispatchCall?.cfg).toBe(runtimeCfg);
expect(
JSON.stringify(firstMockArg(resetInteraction.followUp, "interaction.followUp")),
).toContain("✅ Model set to openai/gpt-5.6-terra.");
expect(resetInteraction.followUp).toHaveBeenCalledOnce();
});
it.each([
{
label: "configured-default request",
suppressedText:
"Model set to openai/gpt-4o for this session. Configured default update requested.",
},
{
label: "immutable configured default",
suppressedText:
"Model set to openai/gpt-4o for this session. Configured default unchanged because configuration is immutable.",
},
{
label: "session-only selection",
suppressedText:
"Model set to openai/gpt-4o for this session only; configured default unchanged.",
},
{
label: "generic fallback",
suppressedText: undefined,
},
])("renders the $label result after authoritative verification", async ({ suppressedText }) => {
const context = createModelPickerContext();
const result = await applyDiscordModelPickerSelection({
interaction: createInteraction() as unknown as PickerButtonInteraction,
selectionCommand: {
prompt: "/model openai/gpt-4o",
command: createModelCommandDefinition(),
},
dispatchCommandInteraction: vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({
accepted: true,
...(suppressedText ? { hiddenFinalReply: { text: `\n ${suppressedText} \n` } } : {}),
}),
cfg: context.cfg,
discordConfig: context.discordConfig,
accountId: context.accountId,
sessionPrefix: context.sessionPrefix,
threadBindings: context.threadBindings,
route: createResolvedAgentRoute(),
resolvedModelRef: "openai/gpt-4o",
preferenceScope: { accountId: "default", userId: "owner" },
settleMs: 0,
resolveCurrentModel: () => "openai/gpt-4o",
resolveCurrentRuntime: () => "auto",
});
expect(result).toEqual({
status: "success",
effectiveModelRef: "openai/gpt-4o",
noticeMessage: suppressedText ?? "✅ Model set to openai/gpt-4o.",
});
});
it("keeps the mismatch warning when the hidden reply looked successful", async () => {
const context = createModelPickerContext();
const interaction = createInteraction();
const recordRecentSpy = vi
.spyOn(modelPickerPreferencesModule, "recordDiscordModelPickerRecentModel")
.mockResolvedValue();
const result = await applyDiscordModelPickerSelection({
interaction: interaction as unknown as PickerButtonInteraction,
selectionCommand: {
prompt: "/model openai/gpt-4o",
command: createModelCommandDefinition(),
},
dispatchCommandInteraction: vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({
accepted: true,
hiddenFinalReply: {
text: "Model set to openai/gpt-4o for this session. Configured default update requested.",
},
}),
cfg: context.cfg,
discordConfig: context.discordConfig,
accountId: context.accountId,
sessionPrefix: context.sessionPrefix,
threadBindings: context.threadBindings,
route: createResolvedAgentRoute(),
resolvedModelRef: "openai/gpt-4o",
preferenceScope: { accountId: "default", userId: "owner" },
settleMs: 0,
resolveCurrentModel: () => "openai/gpt-4.1",
resolveCurrentRuntime: () => "codex",
});
expect(result).toEqual({
status: "mismatch",
effectiveModelRef: "openai/gpt-4.1",
noticeMessage:
"⚠️ Tried to set openai/gpt-4o, but current selection is openai/gpt-4.1 with runtime codex.",
});
expect(recordRecentSpy).not.toHaveBeenCalled();
});
it("reports a hidden model error with the authoritative current selection", async () => {
const context = createModelPickerContext();
const recordRecentSpy = vi
.spyOn(modelPickerPreferencesModule, "recordDiscordModelPickerRecentModel")
.mockResolvedValue();
const resolveCurrentModel = vi.fn(() => "openai/gpt-4.1");
const resolveCurrentRuntime = vi.fn(() => "codex");
const result = await applyDiscordModelPickerSelection({
interaction: createInteraction() as unknown as PickerButtonInteraction,
selectionCommand: {
prompt: "/model openai/gpt-4o",
command: createModelCommandDefinition(),
},
dispatchCommandInteraction: vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({
accepted: true,
hiddenFinalReply: {
text: " Model change was not applied because the session changed. ",
isError: true,
},
}),
cfg: context.cfg,
discordConfig: context.discordConfig,
accountId: context.accountId,
sessionPrefix: context.sessionPrefix,
threadBindings: context.threadBindings,
route: createResolvedAgentRoute(),
resolvedModelRef: "openai/gpt-4o",
preferenceScope: { accountId: "default", userId: "owner" },
settleMs: 0,
resolveCurrentModel,
resolveCurrentRuntime,
});
expect(result).toEqual({
status: "rejected",
noticeMessage:
"Model change was not applied because the session changed.\nCurrent selection: openai/gpt-4.1 with runtime codex.",
});
expect(resolveCurrentModel).toHaveBeenCalledOnce();
expect(resolveCurrentRuntime).toHaveBeenCalledOnce();
expect(recordRecentSpy).not.toHaveBeenCalled();
});
it.each([
{ selectedRuntime: "codex", currentRuntime: "codex", expectedStatus: "success" },
{ selectedRuntime: "auto", currentRuntime: "auto", expectedStatus: "success" },
{ selectedRuntime: "default", currentRuntime: "auto", expectedStatus: "success" },
{ selectedRuntime: "codex", currentRuntime: "auto", expectedStatus: "mismatch" },
])(
"verifies authoritative runtime $selectedRuntime against $currentRuntime",
async ({ selectedRuntime, currentRuntime, expectedStatus }) => {
const context = createModelPickerContext();
const recordRecentSpy = vi
.spyOn(modelPickerPreferencesModule, "recordDiscordModelPickerRecentModel")
.mockResolvedValue();
const result = await applyDiscordModelPickerSelection({
interaction: createInteraction() as unknown as PickerButtonInteraction,
selectionCommand: {
prompt: `/model openai/gpt-4o --runtime ${selectedRuntime}`,
command: createModelCommandDefinition(),
},
dispatchCommandInteraction: vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({
accepted: true,
hiddenFinalReply: { text: "scope-aware core notice" },
}),
cfg: context.cfg,
discordConfig: context.discordConfig,
accountId: context.accountId,
sessionPrefix: context.sessionPrefix,
threadBindings: context.threadBindings,
route: createResolvedAgentRoute(),
resolvedModelRef: "openai/gpt-4o",
selectedRuntime,
preferenceScope: { accountId: "default", userId: "owner" },
settleMs: 0,
resolveCurrentModel: () => "openai/gpt-4o",
resolveCurrentRuntime: () => currentRuntime,
});
expect(result.status).toBe(expectedStatus);
if (expectedStatus === "success") {
expect(result.noticeMessage).toBe("scope-aware core notice");
expect(recordRecentSpy).toHaveBeenCalledOnce();
} else {
expect(result.noticeMessage).toBe(
"⚠️ Tried to set openai/gpt-4o with runtime codex, but current selection is openai/gpt-4o with runtime auto.",
);
expect(recordRecentSpy).not.toHaveBeenCalled();
}
},
);
it("keeps a pending model stable when hot reload reorders the catalog", async () => {
const context = createModelPickerContext();
const runtimeCfg = { ...context.cfg } as OpenClawConfig;
@@ -500,9 +693,7 @@ describe("Discord model picker interactions", () => {
});
expectDispatchedModelSelection({ dispatchSpy, model: "openai/b" });
expect(
JSON.stringify(firstMockArg(submitInteraction.followUp, "interaction.followUp")),
).toContain("✅ Model set to openai/b.");
expect(submitInteraction.followUp).toHaveBeenCalledOnce();
dispatchSpy.mockClear();
const legacyInteraction = await runSubmitButton({
@@ -581,47 +772,41 @@ describe("Discord model picker interactions", () => {
});
});
it("persists the selected runtime outside the hidden /model pipeline", async () => {
const context = createModelPickerContext();
const pickerData = createDefaultModelPickerData();
pickerData.runtimeChoicesByProvider = new Map([
[
"openai",
it.each(["codex", "auto", "default"])(
"routes selected runtime %s through the hidden /model command",
async (runtime) => {
const context = createModelPickerContext();
const pickerData = createDefaultModelPickerData();
pickerData.runtimeChoicesByProvider = new Map([
[
{ id: "codex", label: "Codex", description: "Use Codex." },
{ id: "openclaw", label: "OpenClaw Default", description: "Use OpenClaw." },
"openai",
[
{ id: "codex", label: "Codex", description: "Use Codex." },
{ id: "openclaw", label: "OpenClaw Default", description: "Use OpenClaw." },
],
],
],
]);
const modelCommand = createModelCommandDefinition();
]);
const modelCommand = createModelCommandDefinition();
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const dispatchSpy = createDispatchSpy();
const submitInteraction = await runSubmitButton({
context,
data: { ...createModelsViewSubmitData(), r: "codex" },
dispatchCommandInteraction: dispatchSpy,
});
const dispatchSpy = createDispatchSpy();
const submitInteraction = await runSubmitButton({
context,
data: { ...createModelsViewSubmitData(), r: runtime },
dispatchCommandInteraction: dispatchSpy,
});
expect(submitInteraction.editReply).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expectDispatchedModelSelection({
dispatchSpy,
model: "openai/gpt-4o",
});
const entries = listSessionEntries({
storePath: resolveStorePath(context.cfg.session?.store, { agentId: "main" }),
});
const entry = entries.find(
(candidate) =>
candidate.entry.providerOverride === "openai" && candidate.entry.modelOverride === "gpt-4o",
)?.entry;
expect(typeof entry?.sessionId).toBe("string");
expect(entry?.sessionId).not.toBe("");
expect(entry?.agentRuntimeOverride).toBe("codex");
});
expect(submitInteraction.editReply).toHaveBeenCalledTimes(1);
expect(dispatchSpy).toHaveBeenCalledTimes(1);
expectDispatchedModelSelection({
dispatchSpy,
model: "openai/gpt-4o",
runtime,
});
},
);
it("does not carry the current runtime to another provider", async () => {
const context = createModelPickerContext();
@@ -655,15 +840,30 @@ describe("Discord model picker interactions", () => {
dispatchSpy,
model: "anthropic/claude-sonnet-4-5",
});
const entries = listSessionEntries({
storePath: resolveStorePath(context.cfg.session?.store, { agentId: "main" }),
});
it("keeps legacy model indices in JavaScript code-unit order", async () => {
const context = createModelPickerContext();
const pickerData = createModelsProviderData({
openai: ["a-model", "Z-model"],
});
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(createModelCommandDefinition());
const dispatchSpy = createDispatchSpy();
await runSubmitButton({
context,
data: {
...createModelsViewSubmitData(),
mi: "1",
},
dispatchCommandInteraction: dispatchSpy,
});
expectDispatchedModelSelection({
dispatchSpy,
model: "openai/Z-model",
});
const entry = entries.find(
(candidate) =>
candidate.entry.providerOverride === "anthropic" &&
candidate.entry.modelOverride === "claude-sonnet-4-5",
)?.entry;
expect(entry?.agentRuntimeOverride).toBeUndefined();
});
it("does not treat legacy agentRuntime config as current picker state", async () => {
@@ -697,15 +897,6 @@ describe("Discord model picker interactions", () => {
dispatchSpy,
model: "anthropic/claude-sonnet-4-5",
});
const entries = listSessionEntries({
storePath: resolveStorePath(context.cfg.session?.store, { agentId: "main" }),
});
const entry = entries.find(
(candidate) =>
candidate.entry.providerOverride === "anthropic" &&
candidate.entry.modelOverride === "claude-sonnet-4-5",
)?.entry;
expect(entry?.agentRuntimeOverride).toBeUndefined();
});
it("applies the selected model even when component thread parent.name throws on a partial channel", async () => {
@@ -966,273 +1157,84 @@ describe("Discord model picker interactions", () => {
});
});
it("verifies model state against the bound thread session", async () => {
it("verifies the effective route returned by the core command", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-bound",
targetSessionKey: "agent:worker:subagent:bound",
const effectiveRoute = createResolvedAgentRoute({
agentId: "worker",
});
const pickerData = createDefaultModelPickerData();
const modelCommand = createModelCommandDefinition();
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const dispatchSpy = createDispatchSpy();
const verboseSpy = vi.mocked(logVerbose);
verboseSpy.mockClear();
verboseSpy.mockImplementation(() => {});
const select = createModelPickerFallbackSelect(context, dispatchSpy);
const selectInteraction = createInteraction({
userId: "owner",
values: ["gpt-4o"],
});
selectInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
const selectData = createModelsViewSelectData();
await select.run(selectInteraction as unknown as PickerSelectInteraction, selectData);
const button = createModelPickerFallbackButton(context, dispatchSpy);
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
const submitData = createModelsViewSubmitData();
await button.run(submitInteraction as unknown as PickerButtonInteraction, submitData);
const mismatchLog = verboseSpy.mock.calls.find(
(call) => typeof call[0] === "string" && call[0].includes("model picker override mismatch"),
)?.[0];
expect(mismatchLog).toContain("session key agent:worker:subagent:bound");
});
it("persists suffixed LM Studio model overrides when dispatch leaves the routed session stale", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-bound",
targetSessionKey: "agent:worker:subagent:bound",
agentId: "worker",
});
const pickerData = createModelsProviderData({
anthropic: ["claude-sonnet-4-5"],
lmstudio: ["unsloth/gemma-4-26b-a4b-it@iq4_xs"],
});
const modelCommand = createModelCommandDefinition();
const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
await upsertSessionEntry({
storePath,
sessionKey: "agent:worker:subagent:bound",
entry: {
updatedAt: Date.now(),
sessionId: "bound-session",
authProfileOverride: "lmstudio:work",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
mainSessionKey: "agent:worker:main",
});
const seenRoutes: unknown[] = [];
const result = await applyDiscordModelPickerSelection({
interaction: createInteraction() as unknown as PickerButtonInteraction,
selectionCommand: {
prompt: "/model openai/gpt-4o",
command: createModelCommandDefinition(),
},
dispatchCommandInteraction: vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({
accepted: true,
effectiveRoute,
}),
cfg: context.cfg,
discordConfig: context.discordConfig,
accountId: context.accountId,
sessionPrefix: context.sessionPrefix,
threadBindings: context.threadBindings,
route: createResolvedAgentRoute(),
resolvedModelRef: "openai/gpt-4o",
preferenceScope: { accountId: "default", userId: "owner" },
settleMs: 0,
resolveCurrentModel: (route) => {
seenRoutes.push(route);
return "openai/gpt-4.1";
},
resolveCurrentRuntime: (route) => {
seenRoutes.push(route);
return "auto";
},
});
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const dispatchSpy = createDispatchSpy();
const button = createModelPickerFallbackButton(context, dispatchSpy);
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
await button.run(submitInteraction as unknown as PickerButtonInteraction, {
...createModelsViewSubmitData(),
p: "lmstudio",
mi: "1",
expect(seenRoutes).toEqual([effectiveRoute, effectiveRoute]);
expect(result).toEqual({
status: "mismatch",
effectiveModelRef: "openai/gpt-4.1",
noticeMessage:
"⚠️ Tried to set openai/gpt-4o, but current selection is openai/gpt-4.1 with runtime auto.",
});
const entry = getSessionEntry({ storePath, sessionKey: "agent:worker:subagent:bound" });
expect(entry?.providerOverride).toBe("lmstudio");
expect(entry?.modelOverride).toBe("unsloth/gemma-4-26b-a4b-it@iq4_xs");
expect(entry?.liveModelSwitchPending).toBe(true);
expect(entry?.authProfileOverride).toBe("lmstudio:work");
expect(entry?.authProfileOverrideSource).toBe("user");
expect(entry?.authProfileOverrideCompactionCount).toBe(2);
expectDispatchedModelSelection({
dispatchSpy,
model: "lmstudio/unsloth/gemma-4-26b-a4b-it@iq4_xs",
});
expect(
JSON.stringify(firstMockArg(submitInteraction.followUp, "interaction.followUp")),
).toContain("✅ Model set to lmstudio/unsloth/gemma-4-26b-a4b-it@iq4_xs.");
});
it("preserves auth profiles through provider aliases in the direct persistence fallback", async () => {
it("reports a rejected hidden /model dispatch without reading authoritative state", async () => {
const context = createModelPickerContext();
context.cfg.plugins = { allow: ["byteplus"] };
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-auth-alias",
targetSessionKey: "agent:worker:subagent:auth-alias",
agentId: "worker",
});
const pickerData = createModelsProviderData({
anthropic: ["claude-sonnet-4-5"],
"byteplus-plan": ["ark-code-latest"],
});
const modelCommand = createModelCommandDefinition();
const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
await upsertSessionEntry({
storePath,
sessionKey: "agent:worker:subagent:auth-alias",
entry: {
updatedAt: Date.now(),
sessionId: "auth-alias-session",
authProfileOverride: "byteplus:work",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
const resolveCurrentModel = vi.fn(() => "openai/gpt-4.1");
const resolveCurrentRuntime = vi.fn(() => "auto");
const result = await applyDiscordModelPickerSelection({
interaction: createInteraction() as unknown as PickerButtonInteraction,
selectionCommand: {
prompt: "/model openai/gpt-4o",
command: createModelCommandDefinition(),
},
dispatchCommandInteraction: vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({
accepted: false,
}),
cfg: context.cfg,
discordConfig: context.discordConfig,
accountId: context.accountId,
sessionPrefix: context.sessionPrefix,
threadBindings: context.threadBindings,
route: createResolvedAgentRoute(),
resolvedModelRef: "openai/gpt-4o",
preferenceScope: { accountId: "default", userId: "owner" },
settleMs: 0,
resolveCurrentModel,
resolveCurrentRuntime,
});
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const dispatchSpy = createDispatchSpy();
const button = createModelPickerFallbackButton(context, dispatchSpy);
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-auth-alias",
};
await button.run(submitInteraction as unknown as PickerButtonInteraction, {
...createModelsViewSubmitData(),
p: "byteplus-plan",
mi: "1",
expect(result).toEqual({
status: "rejected",
noticeMessage: "❌ Failed to apply openai/gpt-4o. Try /model openai/gpt-4o directly.",
});
const entry = getSessionEntry({
storePath,
sessionKey: "agent:worker:subagent:auth-alias",
});
expect(entry?.providerOverride).toBe("byteplus-plan");
expect(entry?.modelOverride).toBe("ark-code-latest");
expect(entry?.authProfileOverride).toBe("byteplus:work");
expect(entry?.authProfileOverrideSource).toBe("user");
expect(entry?.authProfileOverrideCompactionCount).toBe(2);
});
it("does not write a fallback override when hidden /model dispatch is rejected", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-bound",
targetSessionKey: "agent:worker:subagent:bound",
agentId: "worker",
});
const pickerData = createDefaultModelPickerData();
const modelCommand = createModelCommandDefinition();
const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
await upsertSessionEntry({
storePath,
sessionKey: "agent:worker:subagent:bound",
entry: {
updatedAt: Date.now(),
sessionId: "bound-session",
},
});
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const button = createModelPickerFallbackButton(
context,
vi.fn<DispatchDiscordCommandInteraction>().mockResolvedValue({ accepted: false }),
);
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
await button.run(
submitInteraction as unknown as PickerButtonInteraction,
createModelsViewSubmitData(),
);
const entry = getSessionEntry({ storePath, sessionKey: "agent:worker:subagent:bound" });
expect(entry?.providerOverride).toBeUndefined();
expect(entry?.modelOverride).toBeUndefined();
expect(
JSON.stringify(firstMockArg(submitInteraction.followUp, "interaction.followUp")),
).toContain("❌ Failed to apply openai/gpt-4o.");
});
it("shows a locked-session rejection without writing a fallback override", async () => {
const context = createModelPickerContext();
context.threadBindings = createBoundThreadBindingManager({
accountId: "default",
threadId: "thread-bound",
targetSessionKey: "agent:worker:subagent:bound",
agentId: "worker",
});
const pickerData = createDefaultModelPickerData();
pickerData.runtimeChoicesByProvider = new Map([
[
"openai",
[
{ id: "codex", label: "Codex", description: "Use Codex." },
{ id: "openclaw", label: "OpenClaw Default", description: "Use OpenClaw." },
],
],
]);
const modelCommand = createModelCommandDefinition();
const storePath = resolveStorePath(context.cfg.session?.store, { agentId: "worker" });
await upsertSessionEntry({
storePath,
sessionKey: "agent:worker:subagent:bound",
entry: {
updatedAt: Date.now(),
sessionId: "bound-session",
providerOverride: "openai",
modelOverride: "gpt-5.5",
agentHarnessId: "codex",
agentRuntimeOverride: "codex",
modelSelectionLocked: true,
},
});
vi.spyOn(modelPickerModule, "loadDiscordModelPickerData").mockResolvedValue(pickerData);
mockModelCommandPipeline(modelCommand);
const button = createModelPickerFallbackButton(context, createDispatchSpy());
const submitInteraction = createInteraction({ userId: "owner" });
submitInteraction.channel = {
type: ChannelType.PublicThread,
id: "thread-bound",
};
await button.run(submitInteraction as unknown as PickerButtonInteraction, {
...createModelsViewSubmitData(),
r: "openclaw",
});
expect(getSessionEntry({ storePath, sessionKey: "agent:worker:subagent:bound" })).toMatchObject(
{
providerOverride: "openai",
modelOverride: "gpt-5.5",
agentHarnessId: "codex",
agentRuntimeOverride: "codex",
modelSelectionLocked: true,
},
);
expect(
JSON.stringify(firstMockArg(submitInteraction.followUp, "interaction.followUp")),
).toContain("❌ Model selection is locked for this session.");
expect(resolveCurrentModel).not.toHaveBeenCalled();
expect(resolveCurrentRuntime).not.toHaveBeenCalled();
});
it("loads model picker data from the effective bound route", async () => {
@@ -15,6 +15,7 @@ import {
createTestRegistry,
setActivePluginRegistry,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { setReplyPayloadMetadata } from "openclaw/plugin-sdk/reply-payload-testing";
import {
clearRuntimeConfigSnapshot,
setRuntimeConfigSnapshot,
@@ -22,6 +23,7 @@ import {
import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { defineThrowingDiscordChannelGetter } from "../test-support/partial-channel.js";
import { dispatchDiscordNativeAgentReply } from "./native-command-agent-reply.js";
import { resolveDiscordNativeInteractionRouteState } from "./native-command-route.js";
import { nativeCommandRuntime } from "./native-command.runtime.js";
import {
@@ -1013,6 +1015,63 @@ describe("Discord native plugin command dispatch", () => {
ephemeral: true,
});
expect(interaction.reply).not.toHaveBeenCalled();
expect(interaction.deleteReply).not.toHaveBeenCalled();
});
it("warns when the inbound turn is dropped before dispatch", async () => {
const cfg = createConfig();
const interaction = createInteraction();
nativeCommandRuntime.dispatchChannelInboundTurn = async () => ({
admission: { kind: "drop", reason: "ingest-null" },
dispatched: false,
});
const result = await dispatchDiscordNativeAgentReply({
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
interaction: interaction as never,
ctxPayload: { SessionKey: "agent:main:discord:dm:owner" } as never,
effectiveRoute: {
accountId: "default",
agentId: "main",
sessionKey: "agent:main:discord:dm:owner",
},
channelConfig: null,
mediaLocalRoots: [],
preferFollowUp: true,
log: { error: vi.fn() } as never,
});
expect(result).toEqual({ dispatched: false });
expectFollowUpFields(interaction, {
content: "⚠️ Command produced no visible reply.",
ephemeral: true,
});
expect(interaction.reply).not.toHaveBeenCalled();
expect(interaction.deleteReply).not.toHaveBeenCalled();
});
it("settles deliberate command silence without an empty warning", async () => {
const cfg = createConfig();
const interaction = createInteraction();
runtimeModuleMocks.matchPluginCommand.mockReturnValue(null);
runtimeModuleMocks.dispatchReplyWithDispatcher.mockResolvedValue({
counts: { final: 0, block: 0, tool: 0 },
queuedFinal: false,
deliberateSilentTerminalReply: true,
} as never);
const command = await createNativeCommand(cfg, {
name: "new",
description: "Start a new session.",
acceptsArgs: true,
});
await (command as { run: (interaction: unknown) => Promise<void> }).run(interaction as unknown);
expect(interaction.followUp).not.toHaveBeenCalled();
expect(interaction.reply).not.toHaveBeenCalled();
expect(interaction.deleteReply).toHaveBeenCalledTimes(1);
});
it("warns when a final delivery observer does not report its outcome", async () => {
@@ -1087,6 +1146,116 @@ describe("Discord native plugin command dispatch", () => {
expect(interaction.deleteReply).toHaveBeenCalledTimes(1);
});
it("preserves a hidden error final and its metadata without sending it to Discord", async () => {
const cfg = createConfig();
const interaction = createInteraction();
interaction.responseState = "deferred";
const finalReply = setReplyPayloadMetadata(
{ text: "scope-aware model selection result", isError: true },
{ assistantMessageIndex: 3 },
);
nativeCommandRuntime.dispatchChannelInboundTurn = async (plan) => {
if (!("deliver" in plan.delivery) || !plan.delivery.deliver) {
throw new Error("expected direct deliverer");
}
const info = { kind: "final" as const };
const deliveryResult = await plan.delivery.deliver(finalReply, info);
await plan.delivery.onDelivered?.(finalReply, info, deliveryResult);
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
counts: { final: 0, block: 0, tool: 0 },
queuedFinal: false,
},
};
};
const result = await dispatchDiscordNativeAgentReply({
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
interaction: interaction as never,
ctxPayload: { SessionKey: "agent:main:discord:dm:owner" } as never,
effectiveRoute: {
accountId: "default",
agentId: "main",
sessionKey: "agent:main:discord:dm:owner",
},
channelConfig: null,
mediaLocalRoots: [],
preferFollowUp: true,
suppressReplies: true,
log: { error: vi.fn() } as never,
});
expect(result.hiddenFinalReply).toBe(finalReply);
expect(result.hiddenFinalReply?.isError).toBe(true);
expect(interaction.followUp).not.toHaveBeenCalled();
expect(interaction.reply).not.toHaveBeenCalled();
expect(interaction.deleteReply).toHaveBeenCalledTimes(1);
});
it.each([
{
label: "hook cancellation",
payload: { text: "cancelled core final" },
suppression: { reason: "cancelled_by_reply_payload_sending_hook" as const },
},
{
label: "empty final",
payload: { text: " " },
suppression: { reason: "no_visible_result" as const },
},
])("does not capture a hidden final for $label", async ({ payload, suppression }) => {
const cfg = createConfig();
const interaction = createInteraction();
interaction.responseState = "deferred";
nativeCommandRuntime.dispatchChannelInboundTurn = async (plan) => {
await plan.delivery.onDelivered?.(
payload,
{ kind: "final" },
{
visibleReplySent: false,
suppression,
},
);
return {
admission: { kind: "dispatch" },
dispatched: true,
ctxPayload: plan.ctxPayload,
routeSessionKey: plan.route.sessionKey,
dispatchResult: {
counts: { final: 0, block: 0, tool: 0 },
queuedFinal: false,
},
};
};
const result = await dispatchDiscordNativeAgentReply({
cfg,
discordConfig: cfg.channels?.discord ?? {},
accountId: "default",
interaction: interaction as never,
ctxPayload: { SessionKey: "agent:main:discord:dm:owner" } as never,
effectiveRoute: {
accountId: "default",
agentId: "main",
sessionKey: "agent:main:discord:dm:owner",
},
channelConfig: null,
mediaLocalRoots: [],
preferFollowUp: true,
suppressReplies: true,
log: { error: vi.fn() } as never,
});
expect(result.hiddenFinalReply).toBeUndefined();
expect(interaction.deleteReply).toHaveBeenCalledTimes(1);
});
it("keeps a native reply visible when a later Discord chunk expires", async () => {
const cfg = createConfig();
const interaction = createInteraction();
@@ -703,7 +703,7 @@ async function dispatchDiscordCommandInteraction(params: {
return directStatusResult;
}
await dispatchDiscordNativeAgentReply({
const { dispatched, hiddenFinalReply } = await dispatchDiscordNativeAgentReply({
cfg,
discordConfig,
accountId,
@@ -718,7 +718,7 @@ async function dispatchDiscordCommandInteraction(params: {
log,
});
return { accepted: true, effectiveRoute };
return { accepted: dispatched, effectiveRoute, hiddenFinalReply };
}
export function createDiscordCommandArgFallbackButton(params: DiscordCommandArgContext): Button {
@@ -1,14 +1,11 @@
import { randomUUID } from "node:crypto";
import { buildCommandsMessagePaginated } from "openclaw/plugin-sdk/command-status";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
applyModelOverrideWithAuthProfileCompatibility,
ModelSelectionLockedError,
} from "openclaw/plugin-sdk/model-session-runtime";
import { applySessionModelSelection } from "openclaw/plugin-sdk/model-session-runtime";
import { formatModelsAvailableHeader } from "openclaw/plugin-sdk/models-provider-runtime";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { logVerbose } from "openclaw/plugin-sdk/runtime-env";
import { patchSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import { getSessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import {
resolveAgentDir,
resolveDefaultAgentId,
@@ -270,51 +267,81 @@ export async function handleTelegramModelCallback(params: {
});
const isDefaultSelection =
selection.provider === resolvedDefault.provider && selection.model === resolvedDefault.model;
const persistedSessionEntry =
sessionState.sessionEntry ??
telegramDeps.getSessionEntry?.({ storePath, sessionKey: sessionState.sessionKey }) ??
getSessionEntry({ storePath, sessionKey: sessionState.sessionKey });
const sessionEntryMissing = persistedSessionEntry === undefined;
const sessionEntry = persistedSessionEntry ?? {
sessionId: randomUUID(),
updatedAt: Date.now(),
};
const previousAuthProfileId = sessionEntry.authProfileOverride?.trim();
const sessionStore = { [sessionState.sessionKey]: sessionEntry };
const modelCatalog = [...byProvider.entries()].flatMap(([provider, models]) =>
[...models].map((model) => ({ provider, id: model, name: model })),
);
const currentModelRef = sessionState.model?.trim();
const currentModelSeparator = currentModelRef?.indexOf("/") ?? -1;
const currentProvider =
currentModelRef && currentModelSeparator > 0
? currentModelRef.slice(0, currentModelSeparator)
: resolvedDefault.provider;
const currentModel =
currentModelRef && currentModelSeparator > 0
? currentModelRef.slice(currentModelSeparator + 1)
: resolvedDefault.model;
let applied: Awaited<ReturnType<typeof applySessionModelSelection>>;
try {
await patchSessionEntry({
storePath,
applied = await applySessionModelSelection({
cfg: runtimeCfg,
agentId: sessionState.agentId,
sessionKey: sessionState.sessionKey,
fallbackEntry: { sessionId: randomUUID(), updatedAt: Date.now() },
replaceEntry: true,
update: (entry) => {
const currentProvider =
entry.providerOverride?.trim() ||
entry.modelProvider?.trim() ||
resolvedDefault.provider;
applyModelOverrideWithAuthProfileCompatibility({
cfg: runtimeCfg,
agentDir: resolveAgentDir(runtimeCfg, sessionState.agentId),
entry,
currentProvider,
selection: {
provider: selection.provider,
model: selection.model,
isDefault: isDefaultSelection,
},
markLiveSwitchPending: true,
});
return entry;
storePath,
sessionEntry,
sessionStore,
allowCreate: sessionEntryMissing,
defaultProvider: resolvedDefault.provider,
defaultModel: resolvedDefault.model,
currentProvider,
currentModel,
allowedModelKeys: new Set(modelCatalog.map((entry) => `${entry.provider}/${entry.id}`)),
modelCatalog,
canPersistStickyModelSelection: false,
request: {
provider: selection.provider,
model: selection.model,
isDefault: isDefaultSelection,
runtime: { kind: "unchanged" },
},
markLiveSwitchPending: true,
});
} catch (err) {
if (err instanceof ModelSelectionLockedError) {
try {
await editMessageWithButtons(`${err.message}`, []);
} catch (editErr) {
throw new TelegramRetryableCallbackError(editErr);
}
return true;
}
throw new TelegramRetryableCallbackError(err);
}
if (applied.status !== "applied") {
await editMessageWithButtons(`${applied.message}`, []);
return true;
}
const defaultAuthProfileNotice =
isDefaultSelection && previousAuthProfileId
? sessionStore[sessionState.sessionKey]?.authProfileOverride?.trim() ===
previousAuthProfileId
? "Compatible auth profile retained."
: "Incompatible auth profile cleared."
: undefined;
const escapeHtml = (text: string) =>
text.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
const actionText = isDefaultSelection
? "reset to default"
: `changed to <b>${escapeHtml(selection.provider)}/${escapeHtml(selection.model)}</b>`;
const runtimeText =
applied.runtimeChange?.kind === "clear"
? "Runtime reset to configured policy."
: "Runtime unchanged.";
const scopeText = isDefaultSelection
? "Session selection cleared. Runtime unchanged. New replies use the agent's configured default."
: `Session-only model selection. Runtime unchanged. Use /model ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)} --runtime &lt;runtime&gt; to switch harnesses. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`;
? `Session model selection cleared.${defaultAuthProfileNotice ? ` ${defaultAuthProfileNotice}` : ""} ${runtimeText} New replies use the agent's configured default.`
: `Session-only model selection. ${runtimeText} Use /model ${escapeHtml(selection.provider)}/${escapeHtml(selection.model)} --runtime &lt;runtime&gt; -s to switch harnesses. The agent default in openclaw.json is unchanged. This chat keeps the model selection across /new and /reset; use /model default -s to clear the session model selection.`;
await editMessageWithButtons(`✅ Model ${actionText}\n\n${scopeText}`, [], {
parse_mode: "HTML",
});
@@ -42,6 +42,7 @@ vi.mock("openclaw/plugin-sdk/conversation-runtime", { spy: true });
const harness = await import("./bot.create-telegram-bot.test-harness.js");
const pluginStateTestRuntime = await import("openclaw/plugin-sdk/plugin-state-test-runtime");
const configMutation = await import("openclaw/plugin-sdk/config-mutation");
const modelSessionRuntime = await import("openclaw/plugin-sdk/model-session-runtime");
const sessionStoreRuntime = await import("openclaw/plugin-sdk/session-store-runtime");
const EYES_EMOJI = "\u{1F440}";
const tempStateDirs: string[] = [];
@@ -5371,8 +5372,11 @@ describe("createTelegramBot", () => {
const runMiddlewareChain = (ctx: Record<string, unknown>) =>
runTelegramTestMiddlewareChain(middlewareUseSpy, ctx, callbackHandler);
const patchSessionEntrySpy = vi.spyOn(sessionStoreRuntime, "patchSessionEntry");
patchSessionEntrySpy.mockRejectedValueOnce(new Error("session store boom"));
const applySessionModelSelectionSpy = vi.spyOn(
modelSessionRuntime,
"applySessionModelSelection",
);
applySessionModelSelectionSpy.mockRejectedValueOnce(new Error("session store boom"));
const ctx = makeCallbackRetryContext({
updateId: 890,
@@ -5385,7 +5389,7 @@ describe("createTelegramBot", () => {
await expect(runMiddlewareChain(ctx)).rejects.toThrow("session store boom");
await runMiddlewareChain(ctx);
} finally {
patchSessionEntrySpy.mockRestore();
applySessionModelSelectionSpy.mockRestore();
}
expect(editMessageTextSpy).toHaveBeenCalledTimes(1);
+176 -72
View File
@@ -2580,7 +2580,7 @@ describe("createTelegramBot", () => {
`${CHECK_MARK_EMOJI} Model reset to default`,
);
expect(String(firstEditMessageTextArg(2))).toContain(
"Session selection cleared. Runtime unchanged. New replies use the agent's configured default.",
"Session model selection cleared. Runtime unchanged. New replies use the agent's configured default.",
);
const entry = readOnlySessionEntry(storePath);
@@ -2590,93 +2590,196 @@ describe("createTelegramBot", () => {
},
);
it("reports when selecting the default clears an incompatible runtime", async () => {
const storePath = createTelegramTestStorePath("model-default-runtime");
const config = {
agents: {
defaults: {
model: "anthropic/claude-opus-4-6",
models: {
"anthropic/claude-opus-4-6": {},
"openai/gpt-5.4": {},
},
},
},
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies OpenClawConfig;
const route = resolveTelegramConversationRoute({
cfg: config,
accountId: "default",
chatId: 1234,
isGroup: false,
senderId: 9,
}).route;
const sessionKey = resolveTelegramConversationBaseSessionKey({
cfg: config,
route,
chatId: 1234,
isGroup: false,
senderId: 9,
});
await upsertSessionEntry({
storePath,
sessionKey,
entry: {
sessionId: "model-default-runtime",
updatedAt: 1,
providerOverride: "openai",
modelOverride: "gpt-5.4",
modelOverrideSource: "user",
agentRuntimeOverride: "codex",
},
});
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
await getTelegramCallbackHandlerForTests()(
createTelegramCallbackContext({
id: "cbq-model-default-runtime",
data: "mdl_sel_anthropic/claude-opus-4-6",
}),
);
const entry = readOnlySessionEntry(storePath);
expect(entry?.providerOverride).toBeUndefined();
expect(entry?.modelOverride).toBeUndefined();
expect(entry?.agentRuntimeOverride).toBeUndefined();
expect(String(firstEditMessageTextArg(2))).toBe(
`${CHECK_MARK_EMOJI} Model reset to default\n\nSession model selection cleared. Runtime reset to configured policy. New replies use the agent's configured default.`,
);
});
describe("model picker auth profile compatibility", () => {
it.each([
{
name: "preserves a compatible auth profile on a same-provider picker switch",
caseId: "same-switch",
defaultProvider: "openai",
defaultModel: "gpt-5",
callbackData: "mdl_sel_openai/gpt-4.1",
expectedProfile: "team:prod",
},
{
name: "clears an incompatible auth profile on a cross-provider picker switch",
caseId: "cross-switch",
defaultProvider: "openai",
defaultModel: "gpt-5",
callbackData: "mdl_sel_anthropic/claude-sonnet-4-5",
expectedProfile: undefined,
},
])("$name", async ({ callbackData, expectedProfile }) => {
onSpy.mockClear();
editMessageTextSpy.mockClear();
{
name: "retains a compatible auth profile on a same-provider default reset",
caseId: "same-default",
defaultProvider: "openai",
defaultModel: "gpt-5",
callbackData: "mdl_sel_openai/gpt-5",
outcomeText: "Compatible auth profile retained.",
expectedProfile: "team:prod",
},
{
name: "clears an incompatible auth profile on a cross-provider default reset",
caseId: "cross-default",
defaultProvider: "anthropic",
defaultModel: "claude-sonnet-4-5",
callbackData: "mdl_sel_anthropic/claude-sonnet-4-5",
outcomeText: "Incompatible auth profile cleared.",
expectedProfile: undefined,
},
])(
"$name",
async ({
caseId,
defaultProvider,
defaultModel,
callbackData,
expectedProfile,
outcomeText,
}) => {
onSpy.mockClear();
editMessageTextSpy.mockClear();
const storePath = createTelegramTestStorePath("model-auth-profile");
const config = {
auth: {
profiles: { "team:prod": { provider: "openai", mode: "api_key" } },
},
agents: {
defaults: {
model: "openai/gpt-5",
models: {
"openai/gpt-4.1": {},
"openai/gpt-5": {},
"anthropic/claude-sonnet-4-5": {},
const storePath = createTelegramTestStorePath(`model-auth-${caseId}`);
const config = {
auth: {
profiles: { "team:prod": { provider: "openai", mode: "api_key" } },
},
agents: {
defaults: {
model: `${defaultProvider}/${defaultModel}`,
models: {
"openai/gpt-4o": {},
"openai/gpt-4.1": {},
"openai/gpt-5": {},
"anthropic/claude-sonnet-4-5": {},
},
},
},
},
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies OpenClawConfig;
const route = resolveTelegramConversationRoute({
cfg: config,
accountId: "default",
chatId: 1234,
isGroup: false,
senderId: 9,
}).route;
const sessionKey = resolveTelegramConversationBaseSessionKey({
cfg: config,
route,
chatId: 1234,
isGroup: false,
senderId: 9,
});
await upsertSessionEntry({
storePath,
sessionKey,
entry: {
sessionId: "model-auth-profile",
updatedAt: 1,
providerOverride: "openai",
modelOverride: "gpt-4o",
authProfileOverride: "team:prod",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
},
});
const buildModelsProviderDataMock = vi.mocked(telegramBotDepsForTest.buildModelsProviderData);
buildModelsProviderDataMock.mockResolvedValueOnce({
byProvider: new Map([
["openai", new Set(["gpt-4.1", "gpt-5"])],
["anthropic", new Set(["claude-sonnet-4-5"])],
]),
providers: ["anthropic", "openai"],
resolvedDefault: { provider: "openai", model: "gpt-5" },
modelNames: new Map(),
});
channels: { telegram: { dmPolicy: "open", allowFrom: ["*"] } },
session: { store: storePath },
} satisfies OpenClawConfig;
const route = resolveTelegramConversationRoute({
cfg: config,
accountId: "default",
chatId: 1234,
isGroup: false,
senderId: 9,
}).route;
const sessionKey = resolveTelegramConversationBaseSessionKey({
cfg: config,
route,
chatId: 1234,
isGroup: false,
senderId: 9,
});
await upsertSessionEntry({
storePath,
sessionKey,
entry: {
sessionId: `model-auth-${caseId}`,
updatedAt: 1,
providerOverride: "openai",
modelOverride: "gpt-4o",
authProfileOverride: "team:prod",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 2,
},
});
vi.mocked(telegramBotDepsForTest.buildModelsProviderData).mockResolvedValueOnce({
byProvider: new Map([
["openai", new Set(["gpt-4o", "gpt-4.1", "gpt-5"])],
["anthropic", new Set(["claude-sonnet-4-5"])],
]),
providers: ["anthropic", "openai"],
resolvedDefault: { provider: defaultProvider, model: defaultModel },
modelNames: new Map(),
});
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
await getTelegramCallbackHandlerForTests()(
createTelegramCallbackContext({
id: `cbq-model-auth-${expectedProfile ? "same" : "cross"}`,
data: callbackData,
}),
);
loadConfig.mockReturnValue(config);
createTelegramBot({ token: "tok", config });
await getTelegramCallbackHandlerForTests()(
createTelegramCallbackContext({
id: `cbq-model-auth-${caseId}`,
data: callbackData,
}),
);
const entry = readOnlySessionEntry(storePath);
expect(entry?.authProfileOverride).toBe(expectedProfile);
expect(entry?.authProfileOverrideSource).toBe(expectedProfile ? "user" : undefined);
expect(entry?.authProfileOverrideCompactionCount).toBe(expectedProfile ? 2 : undefined);
expect(entry?.liveModelSwitchPending).toBe(true);
});
const entry = readOnlySessionEntry(storePath);
expect(entry?.authProfileOverride).toBe(expectedProfile);
expect(entry?.authProfileOverrideSource).toBe(expectedProfile ? "user" : undefined);
expect(entry?.authProfileOverrideCompactionCount).toBe(expectedProfile ? 2 : undefined);
expect(entry?.liveModelSwitchPending).toBe(true);
if (outcomeText) {
expect(entry?.providerOverride).toBeUndefined();
expect(entry?.modelOverride).toBeUndefined();
const confirmation = String(firstEditMessageTextArg(2));
expect(confirmation).toBe(
`${CHECK_MARK_EMOJI} Model reset to default\n\nSession model selection cleared. ${outcomeText} Runtime unchanged. New replies use the agent's configured default.`,
);
expect(confirmation).not.toContain("team:prod");
}
},
);
});
it("renders model callback lists with configured display names", async () => {
@@ -2792,13 +2895,14 @@ describe("createTelegramBot", () => {
expect(editCall[0]).toBe(1234);
expect(editCall[1]).toBe(17);
expect(editCall[2]).toBe(
`${CHECK_MARK_EMOJI} Model changed to <b>openai/gpt-5.4</b>\n\nSession-only model selection. Runtime unchanged. Use /model openai/gpt-5.4 --runtime &lt;runtime&gt; to switch harnesses. The agent default in openclaw.json is unchanged; /reset or a new session may return to that default.`,
`${CHECK_MARK_EMOJI} Model changed to <b>openai/gpt-5.4</b>\n\nSession-only model selection. Runtime unchanged. Use /model openai/gpt-5.4 --runtime &lt;runtime&gt; -s to switch harnesses. The agent default in openclaw.json is unchanged. This chat keeps the model selection across /new and /reset; use /model default -s to clear the session model selection.`,
);
expect(requireRecord(editCall[3], "edit params").parse_mode).toBe("HTML");
const entry = readOnlySessionEntry(storePath);
expect(entry?.providerOverride).toBe("openai");
expect(entry?.modelOverride).toBe("gpt-5.4");
expect(entry?.modelOverrideSource).toBe("user");
expect(answerCallbackQuerySpy).toHaveBeenCalledWith("cbq-model-html-1");
});
@@ -1198,6 +1198,9 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
modelOverride: "claude",
configuredAuthProfileId: "anthropic:verified",
});
expect(state.runWithModelFallbackMock).toHaveBeenCalledWith(
expect.objectContaining({ userLockedAuthProfileId: undefined }),
);
});
it("retries a same-model switch with the runtime carried by the error", async () => {
@@ -4164,6 +4167,9 @@ describe("agentCommand LiveSessionModelSwitchError retry", () => {
await runBasicAgentCommand();
expect(capturedAuthProfileProvider).toBe("codex-cli");
expect(state.runWithModelFallbackMock).toHaveBeenCalledWith(
expect.objectContaining({ userLockedAuthProfileId: "openai:work" }),
);
expect(state.clearSessionAuthProfileOverrideMock).not.toHaveBeenCalled();
});
+3 -8
View File
@@ -2,6 +2,7 @@
import fs from "node:fs";
import path from "node:path";
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import { resolveSessionAuthProfileOverrideSource } from "../config/sessions/auth-profile-override-provenance.js";
import { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js";
export { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js";
import {
@@ -198,9 +199,7 @@ export function resolveAutoFallbackPrimaryProbe(params: {
return undefined;
}
const fallbackAuthProfileId = normalizeOptionalString(entry.authProfileOverride);
const fallbackAuthProfileIdSource =
entry.authProfileOverrideSource ??
(entry.authProfileOverrideCompactionCount !== undefined ? "auto" : undefined);
const fallbackAuthProfileIdSource = resolveSessionAuthProfileOverrideSource(entry);
return {
provider: originProvider,
model: originModel,
@@ -286,11 +285,7 @@ export function clearAutoFallbackPrimaryProbeSelection(
delete entry.modelOverrideRouteResolution;
delete entry.modelOverrideFallbackOriginProvider;
delete entry.modelOverrideFallbackOriginModel;
if (
entry.authProfileOverrideSource === "auto" ||
(entry.authProfileOverrideSource === undefined &&
entry.authProfileOverrideCompactionCount !== undefined)
) {
if (resolveSessionAuthProfileOverrideSource(entry) === "auto") {
delete entry.authProfileOverride;
delete entry.authProfileOverrideSource;
delete entry.authProfileOverrideCompactionCount;
@@ -444,6 +444,47 @@ describe("external cli oauth resolution", () => {
});
});
it("keeps provider discovery when requested profiles belong to another provider", () => {
const expires = Date.now() + 5 * 24 * 60 * 60_000;
const claudeCredential: ClaudeCliCredential = {
type: "oauth",
provider: "anthropic",
access: "claude-cli-access",
refresh: "claude-cli-refresh",
expires,
};
const codexCredential = makeOAuthCredential({
provider: "openai",
access: "codex-cli-access",
refresh: "codex-cli-refresh",
expires,
});
mocks.readClaudeCliCredentialsCached.mockReturnValue(claudeCredential);
mocks.readCodexCliCredentialsCached.mockReturnValue(codexCredential);
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["claude-cli", "openai"],
profileIds: [CLAUDE_CLI_PROFILE_ID],
allowKeychainPrompt: false,
});
expect(profiles).toStrictEqual([
{
profileId: OPENAI_CODEX_DEFAULT_PROFILE_ID,
credential: codexCredential,
persistence: "runtime-only",
},
{
profileId: CLAUDE_CLI_PROFILE_ID,
credential: { ...claudeCredential, provider: "claude-cli" },
persistence: "persisted",
},
]);
expectReaderPolicyCall(mocks.readCodexCliCredentialsCached);
expectReaderPolicyCall(mocks.readClaudeCliCredentialsCached);
expect(mocks.readMiniMaxCliCredentialsCached).not.toHaveBeenCalled();
});
it("skips external cli readers outside the scoped provider set", () => {
const profiles = resolveExternalCliAuthProfiles(makeStore(), {
providerIds: ["opencode-go"],
+1 -1
View File
@@ -1,5 +1,5 @@
/** Runtime auth-profile facade for lazy model selection and fallback paths. */
export { resolveAuthProfileOrder } from "./auth-profiles/order.js";
export { resolveAuthProfileEligibility, resolveAuthProfileOrder } from "./auth-profiles/order.js";
export { ensureAuthProfileStore, loadAuthProfileStoreForRuntime } from "./auth-profiles/store.js";
export {
getSoonestCooldownExpiry,
@@ -276,12 +276,11 @@ function listScopedExternalCliProfileIds(params: {
const requestedProfileIds = Array.from(options?.profileIds ?? [])
.map((value) => value.trim())
.filter((value) => value.length > 0);
if (requestedProfileIds.length > 0) {
return requestedProfileIds.filter((profileId) =>
externalCliProfileIdMatches(providerConfig, profileId, {
allowLegacyNamespace: true,
}),
);
const matchingRequestedProfileIds = requestedProfileIds.filter((profileId) =>
externalCliProfileIdMatches(providerConfig, profileId, { allowLegacyNamespace: true }),
);
if (matchingRequestedProfileIds.length > 0) {
return matchingRequestedProfileIds;
}
const existingProfileIds = Object.keys(store.profiles).filter((profileId) =>
@@ -575,7 +575,7 @@ describe("resolveSessionAuthProfileOverride", () => {
});
});
it("re-resolves a stale user session override when the selected profile becomes unusable", async () => {
it("keeps a valid user override during cooldown when a healthy sibling exists", async () => {
await withAuthState(async (state) => {
const agentDir = state.agentDir();
await fs.mkdir(agentDir, { recursive: true });
@@ -620,9 +620,9 @@ describe("resolveSessionAuthProfileOverride", () => {
isNewSession: false,
});
expect(resolved).toBe(TEST_SECONDARY_PROFILE_ID);
expect(sessionEntry.authProfileOverride).toBe(TEST_SECONDARY_PROFILE_ID);
expect(sessionEntry.authProfileOverrideSource).toBe("auto");
expect(resolved).toBe(TEST_PRIMARY_PROFILE_ID);
expect(sessionEntry.authProfileOverride).toBe(TEST_PRIMARY_PROFILE_ID);
expect(sessionEntry.authProfileOverrideSource).toBe("user");
});
});
@@ -938,25 +938,21 @@ describe("resolveSessionAuthProfileOverride", () => {
: undefined,
usageStats: { [TEST_PRIMARY_PROFILE_ID]: createStats(Date.now() + 60_000) },
});
if (hasHealthySibling) {
authStoreMocks.isProfileInCooldown.mockImplementation(
(_store: AuthProfileStore, profileId: string) => profileId === TEST_PRIMARY_PROFILE_ID,
);
}
authStoreMocks.isProfileInCooldown.mockReturnValue(false);
const sessionEntry = createAutomaticSessionEntry({
model: "model-y",
authProfileOverrideCompactionCount: 0,
});
const sessionStore = { "agent:main:main": sessionEntry };
const resolved = await resolveOpenAiSession({ agentDir, sessionEntry, sessionStore });
const expected = hasHealthySibling ? TEST_SECONDARY_PROFILE_ID : TEST_PRIMARY_PROFILE_ID;
expect(resolved).toBe(expected);
if (!hasHealthySibling) {
expect(sessionEntry.updatedAt).toBe(1);
}
expect(resolved).toBe(TEST_PRIMARY_PROFILE_ID);
expect(authStoreMocks.isProfileInCooldown).toHaveBeenCalledWith(
expect.anything(),
TEST_PRIMARY_PROFILE_ID,
undefined,
"model-y",
);
});
},
);
+20 -30
View File
@@ -3,6 +3,7 @@
* Keeps automatic profile choice stable within a session while still rotating
* across new sessions, compactions, provider changes, and cooldowns.
*/
import { resolveSessionAuthProfileOverrideSource } from "../../config/sessions/auth-profile-override-provenance.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
@@ -272,13 +273,7 @@ export async function resolveSessionAuthProfileOverride(params: {
),
];
let current = sessionEntry.authProfileOverride?.trim();
const source =
sessionEntry.authProfileOverrideSource ??
(typeof sessionEntry.authProfileOverrideCompactionCount === "number"
? "auto"
: current
? "user"
: undefined);
const source = resolveSessionAuthProfileOverrideSource(sessionEntry);
const currentProfileId = current;
if (
@@ -301,8 +296,13 @@ export async function resolveSessionAuthProfileOverride(params: {
current = undefined;
}
// Explicit user picks should survive provider rotation order changes.
if (current && order.length > 0 && !order.includes(current) && source !== "user") {
// Explicit user pins are strict until the profile disappears or changes provider.
if (source === "user" && current) {
return current;
}
// Automatic pins must stay inside the currently configured rotation order.
if (current && order.length > 0 && !order.includes(current)) {
await clearSessionAuthProfileOverride({ sessionEntry, sessionStore, sessionKey, storePath });
current = undefined;
}
@@ -311,10 +311,7 @@ export async function resolveSessionAuthProfileOverride(params: {
return undefined;
}
if (
(source !== "user" || !current) &&
order.every((profileId) => isProfileGloballyInCooldown(store, profileId))
) {
if (order.every((profileId) => isProfileGloballyInCooldown(store, profileId))) {
// An automatic pin must not trap later turns on an unavailable provider.
if (current) {
const latest = await persistSessionAuthProfileOverrideState({
@@ -335,13 +332,7 @@ export async function resolveSessionAuthProfileOverride(params: {
},
});
const latestProfileId = latest?.authProfileOverride;
const latestSource =
latest?.authProfileOverrideSource ??
(typeof latest?.authProfileOverrideCompactionCount === "number"
? "auto"
: latestProfileId
? "user"
: undefined);
const latestSource = resolveSessionAuthProfileOverrideSource(latest);
return latestProfileId &&
latestSource === "user" &&
isProfileForProvider({ cfg, providers, profileId: latestProfileId, store })
@@ -351,8 +342,10 @@ export async function resolveSessionAuthProfileOverride(params: {
return undefined;
}
const isProfileUnavailableForSessionModel = (profileId: string) =>
isProfileInCooldown(store, profileId, undefined, sessionEntry.model);
const pickFirstAvailable = () =>
order.find((profileId) => !isProfileInCooldown(store, profileId)) ?? order[0];
order.find((profileId) => !isProfileUnavailableForSessionModel(profileId)) ?? order[0];
const pickNextAvailable = (active: string) => {
const startIndex = order.indexOf(active);
if (startIndex < 0) {
@@ -360,7 +353,7 @@ export async function resolveSessionAuthProfileOverride(params: {
}
for (let offset = 1; offset <= order.length; offset += 1) {
const candidate = order[(startIndex + offset) % order.length];
if (candidate && !isProfileInCooldown(store, candidate)) {
if (candidate && !isProfileUnavailableForSessionModel(candidate)) {
return candidate;
}
}
@@ -373,17 +366,14 @@ export async function resolveSessionAuthProfileOverride(params: {
? sessionEntry.authProfileOverrideCompactionCount
: compactionCount;
const replacementForUnusableCurrent =
current && isProfileInCooldown(store, current)
? order.find((profileId) => profileId !== current && !isProfileInCooldown(store, profileId))
current && isProfileUnavailableForSessionModel(current)
? order.find(
(profileId) => profileId !== current && !isProfileUnavailableForSessionModel(profileId),
)
: undefined;
// User-pinned profiles persist unless unusable/mismatched. Auto-selected
// profiles rotate on new sessions or compaction boundaries.
if (replacementForUnusableCurrent) {
current = undefined;
}
if (source === "user" && current && !isNewSession) {
return current;
}
let next = current;
if (replacementForUnusableCurrent) {
@@ -392,7 +382,7 @@ export async function resolveSessionAuthProfileOverride(params: {
next = current ? pickNextAvailable(current) : pickFirstAvailable();
} else if (current && compactionCount > storedCompaction) {
next = pickNextAvailable(current);
} else if (!current || isProfileInCooldown(store, current)) {
} else if (!current || isProfileUnavailableForSessionModel(current)) {
next = pickFirstAvailable();
}
@@ -0,0 +1,161 @@
// Focused lifecycle coverage for explicit auth-profile pins.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { resolveSessionAuthProfileOverride } from "./session-override.js";
import type { AuthProfileStore } from "./types.js";
const PRIMARY_PROFILE_ID = "openai:primary@example.test";
const SECONDARY_PROFILE_ID = "openai:secondary@example.test";
const authStoreMocks = vi.hoisted(() => {
const state: { store: AuthProfileStore } = {
store: { version: 1, profiles: {} },
};
return {
state,
isProfileInCooldown: vi.fn(() => false),
};
});
vi.mock("./store.js", () => ({
ensureAuthProfileStore: () => authStoreMocks.state.store,
hasAnyAuthProfileStoreSource: () => true,
}));
vi.mock("./order.js", () => ({
isStoredCredentialCompatibleWithAuthProvider: ({
provider,
credential,
}: {
provider: string;
credential: { provider: string };
}) => credential.provider === provider,
isConfiguredAwsSdkAuthProfileForProvider: () => false,
resolveAuthProfileOrder: ({ store, provider }: { store: AuthProfileStore; provider: string }) =>
store.order?.[provider] ?? [],
}));
vi.mock("./usage.js", () => ({
isProfileInCooldown: authStoreMocks.isProfileInCooldown,
}));
function createStore(order: string[]): AuthProfileStore {
return {
version: 1,
profiles: {
[PRIMARY_PROFILE_ID]: {
type: "api_key",
provider: "openai",
key: "sk-primary",
},
[SECONDARY_PROFILE_ID]: {
type: "api_key",
provider: "openai",
key: "sk-secondary",
},
},
order: { openai: order },
};
}
async function resolveSession(params: {
sessionEntry: SessionEntry;
isNewSession: boolean;
}): Promise<string | undefined> {
return await resolveSessionAuthProfileOverride({
cfg: {} as OpenClawConfig,
provider: "openai",
agentDir: "/tmp/agent",
sessionEntry: params.sessionEntry,
sessionStore: { "agent:main:main": params.sessionEntry },
sessionKey: "agent:main:main",
storePath: undefined,
isNewSession: params.isNewSession,
});
}
describe("explicit auth-profile pin lifecycle", () => {
beforeEach(() => {
authStoreMocks.state.store = createStore([PRIMARY_PROFILE_ID, SECONDARY_PROFILE_ID]);
authStoreMocks.isProfileInCooldown.mockReset();
authStoreMocks.isProfileInCooldown.mockReturnValue(false);
});
it.each([
{
name: "empty configured order",
order: [] as string[],
isNewSession: false,
compactionCount: 0,
authProfileOverrideCompactionCount: 0,
},
{
name: "new session",
order: [PRIMARY_PROFILE_ID, SECONDARY_PROFILE_ID],
isNewSession: true,
compactionCount: 0,
authProfileOverrideCompactionCount: 0,
},
{
name: "compaction advance",
order: [PRIMARY_PROFILE_ID, SECONDARY_PROFILE_ID],
isNewSession: false,
compactionCount: 2,
authProfileOverrideCompactionCount: 1,
},
])(
"preserves a valid user pin across $name",
async ({ order, isNewSession, compactionCount, authProfileOverrideCompactionCount }) => {
authStoreMocks.state.store = createStore(order);
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: 1,
compactionCount,
authProfileOverride: PRIMARY_PROFILE_ID,
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount,
};
const resolved = await resolveSession({ sessionEntry, isNewSession });
expect(resolved).toBe(PRIMARY_PROFILE_ID);
expect(sessionEntry).toMatchObject({
updatedAt: 1,
authProfileOverride: PRIMARY_PROFILE_ID,
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount,
});
},
);
it("preserves a legacy source-less user pin on a new session", async () => {
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: 1,
compactionCount: 0,
authProfileOverride: PRIMARY_PROFILE_ID,
};
const resolved = await resolveSession({ sessionEntry, isNewSession: true });
expect(resolved).toBe(PRIMARY_PROFILE_ID);
expect(sessionEntry.authProfileOverride).toBe(PRIMARY_PROFILE_ID);
});
it("still rotates a legacy source-less automatic pin on a new session", async () => {
const sessionEntry: SessionEntry = {
sessionId: "s1",
updatedAt: 1,
compactionCount: 0,
authProfileOverride: PRIMARY_PROFILE_ID,
authProfileOverrideCompactionCount: 0,
};
const resolved = await resolveSession({ sessionEntry, isNewSession: true });
expect(resolved).toBe(SECONDARY_PROFILE_ID);
expect(sessionEntry.authProfileOverride).toBe(SECONDARY_PROFILE_ID);
expect(sessionEntry.authProfileOverrideSource).toBe("auto");
});
});
+2 -4
View File
@@ -9,6 +9,7 @@ import type { ReplyPayload } from "../auto-reply/reply-payload.js";
import type { ReasoningLevel, ThinkLevel } from "../auto-reply/thinking.js";
import type { ChatType } from "../channels/chat-type.js";
import type { SessionEntry as StoredSessionEntry } from "../config/sessions.js";
import { resolveSessionAuthProfileOverrideSource } from "../config/sessions/auth-profile-override-provenance.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import { streamWithPayloadPatch } from "../llm/providers/stream-wrappers/stream-payload-utils.js";
import type {
@@ -131,10 +132,7 @@ function resolveReturnedAuthProfileSource(
if (sessionEntry?.authProfileOverride?.trim() !== authProfileId) {
return "auto";
}
return (
sessionEntry.authProfileOverrideSource ??
(typeof sessionEntry.authProfileOverrideCompactionCount === "number" ? "auto" : "user")
);
return resolveSessionAuthProfileOverrideSource(sessionEntry);
}
// Planning and immediate resolution share one scoped snapshot so provider
@@ -311,6 +311,122 @@ describe("runPreparedCliAgent context engine lifecycle", () => {
expect(dispose).not.toHaveBeenCalled();
});
it("does not emit CLI turn facts without transcript admission", async () => {
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const maintain = vi.fn<NonNullable<ContextEngine["maintain"]>>(async () =>
createMaintenanceResult(),
);
const dispose = vi.fn(async () => {});
const context = buildPreparedContext(createContextEngine({ afterTurn, maintain, dispose }));
const onContextEngineTurnCandidate = vi.fn();
context.params.onContextEngineTurnCandidate = onContextEngineTurnCandidate;
prepareCliRunContextMock.mockResolvedValue(context);
await runCliAgent(context.params);
expect(onContextEngineTurnCandidate).not.toHaveBeenCalled();
expect(afterTurn).not.toHaveBeenCalled();
expect(maintain).toHaveBeenCalledTimes(1);
expect(dispose).not.toHaveBeenCalled();
});
it("uses the admitted user anchor for an accepted transcriptless CLI turn", async () => {
const context = buildPreparedContext(createContextEngine());
executePreparedCliRunMock.mockResolvedValue({
text: "",
rawText: "",
didSendViaMessagingTool: true,
sessionId: "external-cli-session-1",
usage: { input: 11, output: 0, total: 11 },
diagnosticUsage: { input: 21, output: 0, total: 21 },
finalPromptText: "prompt sent to cli",
});
const admission = {
agentId: "main",
sessionId: "openclaw-session-1",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-cli-context-engine-test/sessions.json",
generation: "generation-1",
entryId: "cli-user",
rawSeq: 1,
effectiveParentId: null,
activeMessagePosition: 0,
logicalTurnId: "cli-turn",
role: "user" as const,
};
const onContextEngineTurnCandidate = vi.fn();
context.params.onContextEngineTurnCandidate = onContextEngineTurnCandidate;
context.params.userTurnTranscriptRecorder = {
message: undefined,
resolveMessage: vi.fn(async () => undefined),
getAdmissionReceipt: () => admission,
markRuntimePersistencePending: vi.fn(),
markRuntimePersisted: vi.fn(),
markBlocked: vi.fn(),
hasPersisted: () => true,
isBlocked: () => false,
hasRuntimePersistencePending: () => false,
waitForRuntimePersistence: vi.fn(async () => {}),
persistApproved: vi.fn(async () => undefined),
persistBlocked: vi.fn(async () => undefined),
persistFallback: vi.fn(async () => undefined),
};
await runPreparedCliAgent(context);
expect(onContextEngineTurnCandidate).toHaveBeenCalledWith(
expect.objectContaining({
boundary: { admission, terminal: admission },
}),
);
});
it("uses the admitted user anchor as the terminal for transcriptless room events", async () => {
const context = buildPreparedContext(createContextEngine());
const admission = {
agentId: "main",
sessionId: "openclaw-session-1",
sessionKey: "agent:main:main",
storePath: "/tmp/openclaw-cli-context-engine-test/sessions.json",
generation: "generation-1",
entryId: "room-event-user",
rawSeq: 1,
effectiveParentId: null,
activeMessagePosition: 0,
logicalTurnId: "room-event-turn",
role: "user" as const,
};
const onContextEngineTurnCandidate = vi.fn();
context.params.currentInboundEventKind = "room_event";
context.params.persistAssistantTranscript = false;
context.params.onContextEngineTurnCandidate = onContextEngineTurnCandidate;
context.params.userTurnTranscriptRecorder = {
message: undefined,
resolveMessage: vi.fn(async () => undefined),
getAdmissionReceipt: () => admission,
markRuntimePersistencePending: vi.fn(),
markRuntimePersisted: vi.fn(),
markBlocked: vi.fn(),
hasPersisted: () => true,
isBlocked: () => false,
hasRuntimePersistencePending: () => false,
waitForRuntimePersistence: vi.fn(async () => {}),
persistApproved: vi.fn(async () => undefined),
persistBlocked: vi.fn(async () => undefined),
persistFallback: vi.fn(async () => undefined),
};
await runPreparedCliAgent(context);
expect(onContextEngineTurnCandidate).toHaveBeenCalledWith(
expect.objectContaining({
boundary: { admission, terminal: admission },
sessionIdUsed: "openclaw-session-1",
sessionKey: "agent:main:main",
}),
);
});
it("does not synthesize a context-engine user turn for empty transcript prompts", async () => {
const afterTurn = vi.fn<NonNullable<ContextEngine["afterTurn"]>>(async () => {});
const dispose = vi.fn(async () => {});
+94 -43
View File
@@ -418,13 +418,27 @@ async function persistCliAssistantTranscript(params: {
cacheWrite?: number;
total?: number;
};
}): Promise<boolean> {
}): Promise<{
owned: boolean;
terminalAnchor?: import("../config/sessions/session-accessor.js").TranscriptEntryAnchor;
}> {
const { runParams } = params;
if (!runParams.persistAssistantTranscript || !runParams.sessionKey || !params.text) {
return false;
}
if (runParams.currentInboundEventKind === "room_event") {
return true;
const admission = runParams.userTurnTranscriptRecorder?.getAdmissionReceipt();
return {
owned: true,
...(admission ? { terminalAnchor: admission } : {}),
};
}
if (!params.text) {
const admission = runParams.userTurnTranscriptRecorder?.getAdmissionReceipt();
return {
owned: false,
...(admission ? { terminalAnchor: admission } : {}),
};
}
if (!runParams.persistAssistantTranscript || !runParams.sessionKey) {
return { owned: false };
}
try {
const result = await appendExactAssistantMessageToSessionTranscript({
@@ -454,12 +468,12 @@ async function persistCliAssistantTranscript(params: {
});
if (!result.ok) {
log.warn(`CLI assistant transcript persistence skipped: ${result.reason}`);
return result.code === "blocked" || result.code === "session-rebound";
return { owned: result.code === "blocked" || result.code === "session-rebound" };
}
return true;
return { owned: true, ...(result.anchor ? { terminalAnchor: result.anchor } : {}) };
} catch (error) {
log.warn(`CLI assistant transcript persistence failed: ${formatErrorMessage(error)}`);
return false;
return { owned: false };
}
}
@@ -479,6 +493,7 @@ async function finalizeCliContextEngineTurn(params: {
context: PreparedCliRunContext;
historyMessages: unknown[];
assistantText: string;
terminalAnchor?: import("../config/sessions/session-accessor.js").TranscriptEntryAnchor;
output: Awaited<
ReturnType<typeof import("./cli-runner/execute.runtime.js").executePreparedCliRun>
>;
@@ -505,36 +520,71 @@ async function finalizeCliContextEngineTurn(params: {
);
}
let deferredTurnMaintenance: Promise<void> | undefined;
const contextEngineHostSupport = buildGenericCliContextEngineHostSupport({
backendId: context.backendResolved.id,
});
const result = await finalizeHarnessContextEngineTurn({
contextEngine: context.contextEngine,
promptError: false,
aborted: runParams.abortSignal?.aborted === true,
yieldAborted: false,
sessionIdUsed: runParams.sessionId,
sessionKey: runParams.sessionKey,
sessionFile: runParams.sessionFile,
isHeartbeat: isHeartbeatLifecycleRunKind(runParams.bootstrapContextRunKind),
messagesSnapshot: [...prePromptMessages, ...turnMessages],
prePromptMessageCount: prePromptMessages.length,
config: context.contextEngineConfig,
contextEngineHostSupport,
providerId: runParams.provider,
modelId: context.modelId,
runMaintenance: async (maintenanceParams) =>
await runHarnessContextEngineMaintenance({
...maintenanceParams,
onDeferredMaintenance: (promise) => {
deferredTurnMaintenance = promise;
},
}),
warn: (message) => log.warn(message),
});
if (result.postTurnFinalizationSucceeded && deferredTurnMaintenance) {
context.contextEngineDeferredTurnMaintenance = deferredTurnMaintenance;
const finalizeTurn = async (transcript: {
messagesSnapshot: AgentMessage[];
prePromptMessageCount: number;
sessionManager?: SessionManager;
withSessionManagerRewriteLock: <T>(operation: () => Promise<T> | T) => Promise<T>;
}) => {
let deferredTurnMaintenance: Promise<void> | undefined;
const result = await finalizeHarnessContextEngineTurn({
contextEngine: context.contextEngine,
promptError: false,
aborted: runParams.abortSignal?.aborted === true,
yieldAborted: false,
sessionIdUsed: runParams.sessionId,
sessionKey: runParams.sessionKey,
sessionFile: runParams.sessionFile,
isHeartbeat: isHeartbeatLifecycleRunKind(runParams.bootstrapContextRunKind),
messagesSnapshot: transcript.messagesSnapshot,
prePromptMessageCount: transcript.prePromptMessageCount,
sessionManager: transcript.sessionManager,
config: context.contextEngineConfig,
contextEngineHostSupport,
providerId: runParams.provider,
modelId: context.modelId,
runMaintenance: async (maintenanceParams) =>
await runHarnessContextEngineMaintenance({
...maintenanceParams,
withSessionManagerRewriteLock: transcript.withSessionManagerRewriteLock,
onDeferredMaintenance: (promise) => {
deferredTurnMaintenance = promise;
},
}),
warn: (message) => log.warn(message),
});
if (result.postTurnFinalizationSucceeded && deferredTurnMaintenance) {
context.contextEngineDeferredTurnMaintenance = deferredTurnMaintenance;
}
};
const admission = runParams.userTurnTranscriptRecorder?.getAdmissionReceipt();
if (runParams.onContextEngineTurnCandidate) {
if (admission && params.terminalAnchor) {
runParams.onContextEngineTurnCandidate({
boundary: { admission, terminal: params.terminalAnchor },
sessionIdUsed: runParams.sessionId,
sessionKey: runParams.sessionKey,
sessionTarget: runParams.sessionTarget,
sessionFile: runParams.sessionFile,
promptError: false,
aborted: runParams.abortSignal?.aborted === true,
yieldAborted: false,
contextEngineHostSupport,
providerId: runParams.provider,
modelId: context.modelId,
config: context.contextEngineConfig,
isHeartbeat: isHeartbeatLifecycleRunKind(runParams.bootstrapContextRunKind),
});
}
} else {
await finalizeTurn({
messagesSnapshot: [...prePromptMessages, ...turnMessages],
prePromptMessageCount: prePromptMessages.length,
withSessionManagerRewriteLock: async (operation) => await operation(),
});
}
}
@@ -1460,13 +1510,7 @@ export async function runPreparedCliAgent(
try {
await assertSuccessfulCliRuntimeBindingCurrent(context);
const effectiveCliSessionId = output.sessionId ?? fallbackCliSessionId;
await finalizeCliContextEngineTurn({
context,
historyMessages: context.contextEngine ? contextEngineHistoryMessages : historyMessages,
assistantText,
output,
});
const assistantTranscriptOwned = await persistCliAssistantTranscript({
const assistantTranscript = await persistCliAssistantTranscript({
runParams: params,
// Dispatch owns source-reply transcript mirrors and their idempotency keys.
// Persisting them here would duplicate the same visible assistant reply.
@@ -1474,6 +1518,13 @@ export async function runPreparedCliAgent(
modelId: context.modelId,
usage: output.usage,
});
await finalizeCliContextEngineTurn({
context,
historyMessages: context.contextEngine ? contextEngineHistoryMessages : historyMessages,
assistantText,
terminalAnchor: assistantTranscript.terminalAnchor,
output,
});
// A stateless backend may emit an id, but it never becomes continuity.
// Managed stdio sessions own continuity in-process and write no native transcript.
const bindingFlushOk = sessionBindingDisabled
@@ -1497,7 +1548,7 @@ export async function runPreparedCliAgent(
output,
effectiveCliSessionId,
bindingFlushOk,
assistantTranscriptOwned,
assistantTranscriptOwned: assistantTranscript.owned,
usedHistoryPrompt,
});
} catch (error) {
+27 -7
View File
@@ -72,6 +72,7 @@ import {
makeBootstrapWarn as makeBootstrapWarnImpl,
resolveBootstrapContextForRun as resolveBootstrapContextForRunImpl,
} from "../bootstrap-files.js";
import { isHeartbeatLifecycleRunKind } from "../bootstrap-mode.js";
import { isPrimaryBootstrapRun, resolveWorkspaceBootstrapRouting } from "../bootstrap-routing.js";
import {
CLI_AUTH_EPOCH_VERSION,
@@ -102,6 +103,8 @@ import {
mapSandboxSkillEntriesForPrompt,
resolveSandboxSkillRuntimeInputs,
} from "../embedded-agent-runner/sandbox-skills.js";
import { selectContextEngineForTranscriptHost } from "../harness/context-engine-logical-turn.js";
import { drainPendingContextEngineTurnsBeforeRun } from "../harness/context-engine-turn-attempt.js";
import { resolveHeartbeatPromptForSystemPrompt } from "../heartbeat-system-prompt.js";
import type { ResolvedProviderAuth } from "../model-auth-runtime-shared.js";
import { applyPluginTextReplacements } from "../plugin-text-transforms.js";
@@ -1665,20 +1668,37 @@ export async function prepareCliRunContext(
// Context remains session-owned. Trusted helper runs may borrow a different
// agentDir only for model/auth execution.
const contextEngineAgentDir = resolveAgentDir(contextEngineConfig, contextEngineSessionAgentId);
const resolvedContextEngine = await resolveContextEngine(contextEngineConfig, {
agentDir: contextEngineAgentDir,
workspaceDir,
const contextEngineHostSupport = buildGenericCliContextEngineHostSupport({
backendId: backendResolved.id,
capabilities: backendResolved.contextEngineHostCapabilities,
});
let resolvedContextEngine;
if (params.contextEngineLogicalTurnLease) {
selectContextEngineForTranscriptHost({
lease: params.contextEngineLogicalTurnLease,
host: contextEngineHostSupport,
operation: "agent-run",
recorder: params.userTurnTranscriptRecorder,
});
await drainPendingContextEngineTurnsBeforeRun({
admission: params.userTurnTranscriptRecorder?.getAdmissionReceipt(),
isHeartbeat: isHeartbeatLifecycleRunKind(params.bootstrapContextRunKind),
lease: params.contextEngineLogicalTurnLease,
});
resolvedContextEngine = params.contextEngineLogicalTurnLease.begin().engine;
} else {
resolvedContextEngine = await resolveContextEngine(contextEngineConfig, {
agentDir: contextEngineAgentDir,
workspaceDir,
});
}
const contextEngine =
resolvedContextEngine.info.id !== "legacy" ? resolvedContextEngine : undefined;
if (contextEngine) {
assertContextEngineHostSupport({
contextEngine,
operation: "agent-run",
host: buildGenericCliContextEngineHostSupport({
backendId: backendResolved.id,
capabilities: backendResolved.contextEngineHostCapabilities,
}),
host: contextEngineHostSupport,
});
}
const hadSessionFile = await hasCliSessionTranscript({
+6
View File
@@ -50,6 +50,8 @@ import type {
} from "../embedded-agent-runner/run/params.js";
import type { ExecPolicyOverrides } from "../exec-defaults.js";
import type { FastModeAutoProgressState } from "../fast-mode.js";
import type { ContextEngineLogicalTurnLease } from "../harness/context-engine-logical-turn.js";
import type { ContextEngineTurnAttemptFacts } from "../harness/context-engine-turn-attempt.js";
import type { ScheduledToolPolicyContext } from "../scheduled-tool-policy.js";
import type { SessionManager } from "../sessions/index.js";
import type { SilentReplyPromptMode } from "../system-prompt.types.js";
@@ -104,6 +106,10 @@ export type RunCliAgentParams = {
storePath?: string;
/** Canonical user-turn recorder shared with gateway/queue dispatch. */
userTurnTranscriptRecorder?: UserTurnTranscriptRecorder;
/** Context engine resolved once by the outer logical-turn owner. */
contextEngineLogicalTurnLease?: ContextEngineLogicalTurnLease;
/** Attempt-local facts accepted or discarded by the outer logical-turn owner. */
onContextEngineTurnCandidate?: (facts: ContextEngineTurnAttemptFacts) => void;
/** Skip current-turn user persistence when a retry/fallback already wrote it. */
suppressNextUserMessagePersistence?: boolean;
/** Notification fired after the current user turn has been accepted into the transcript. */
@@ -3610,6 +3610,22 @@ describe("CLI attempt execution", () => {
});
});
it("replaces a legacy marker-backed automatic profile with the configured model profile", async () => {
const embeddedArg = await runOpenClawEmbeddedAttemptForTest({
runId: "configured-auth-replaces-legacy-auto",
configuredAuthProfileId: "openai:verified",
sessionEntry: {
authProfileOverride: "openai:legacy-auto",
authProfileOverrideCompactionCount: 0,
},
});
expectRecordFields(embeddedArg, {
authProfileId: "openai:verified",
authProfileIdSource: "user",
});
});
it("preserves an explicit session profile over the configured model profile", async () => {
const embeddedArg = await runOpenClawEmbeddedAttemptForTest({
runId: "session-auth-over-configured",
@@ -3625,6 +3641,21 @@ describe("CLI attempt execution", () => {
authProfileIdSource: "user",
});
});
it("preserves a legacy source-less user profile over the configured model profile", async () => {
const embeddedArg = await runOpenClawEmbeddedAttemptForTest({
runId: "legacy-session-auth-over-configured",
configuredAuthProfileId: "openai:verified",
sessionEntry: {
authProfileOverride: "openai:legacy-user",
},
});
expectRecordFields(embeddedArg, {
authProfileId: "openai:legacy-user",
authProfileIdSource: "user",
});
});
});
describe("embedded attempt harness pinning", () => {
+12 -1
View File
@@ -20,6 +20,7 @@ import {
} from "../../auto-reply/reply/source-turn-id.js";
import { messageToolOwnsVisibleReply } from "../../auto-reply/source-reply-delivery-mode.js";
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
import { resolveSessionAuthProfileOverrideSource } from "../../config/sessions/auth-profile-override-provenance.js";
import { persistSessionTranscriptTurn } from "../../config/sessions/session-accessor.js";
import { acquireOwnedSessionTranscriptWriteLock } from "../../config/sessions/transcript-write-context.js";
import { readTailAssistantTextFromSessionTranscript } from "../../config/sessions/transcript.js";
@@ -80,6 +81,8 @@ import type {
RunEmbeddedAgentInternalParams,
} from "../embedded-agent-runner/run/internal-params.js";
import type { EmbeddedAgentRunResult } from "../embedded-agent.js";
import type { ContextEngineLogicalTurnLease } from "../harness/context-engine-logical-turn.js";
import type { ContextEngineTurnAttemptFacts } from "../harness/context-engine-turn-attempt.js";
import { runAgentHarnessBeforeMessageWriteHook } from "../harness/hook-helpers.js";
import { resolveAvailableAgentHarnessPolicy } from "../harness/selection.js";
import { resolveCliRuntimeExecutionProvider } from "../model-runtime-aliases.js";
@@ -573,14 +576,16 @@ export function runAgentAttempt(params: {
fallbackRuntimeState?: { originRuntime?: "cli" | "embedded" };
suppressPromptPersistenceOnRetry?: boolean;
userTurnTranscriptRecorder?: UserTurnTranscriptRecorder;
contextEngineLogicalTurnLease?: ContextEngineLogicalTurnLease;
onUserMessagePersisted?: (message: Extract<AgentMessage, { role: "user" }>) => void;
onContextEngineTurnCandidate?: (facts: ContextEngineTurnAttemptFacts) => void;
onLifecycleGenerationChanged?: (
lifecycleGeneration: string,
attribution?: AgentExecutionAttribution,
) => void;
}) {
const sessionAuthProfileId = params.sessionEntry?.authProfileOverride?.trim();
const sessionAuthProfileSource = params.sessionEntry?.authProfileOverrideSource;
const sessionAuthProfileSource = resolveSessionAuthProfileOverrideSource(params.sessionEntry);
// An explicit session choice owns the conversation. Otherwise the profile
// bound to the configured model replaces a stale automatic session choice.
const selectedAuthProfile =
@@ -951,6 +956,8 @@ export function runAgentAttempt(params: {
trigger: "user",
sessionFile: params.sessionFile,
storePath: params.storePath,
persistAssistantTranscript:
params.storePath !== undefined && params.sessionStore !== undefined,
workspaceDir: params.workspaceDir,
cwd: params.cwd,
config: params.cfg,
@@ -1051,6 +1058,8 @@ export function runAgentAttempt(params: {
cleanupCliLiveSessionOnRunEnd: params.opts.cleanupCliLiveSessionOnRunEnd,
oneShotCliRun: params.opts.oneShotCliRun,
userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
contextEngineLogicalTurnLease: params.contextEngineLogicalTurnLease,
onContextEngineTurnCandidate: params.onContextEngineTurnCandidate,
suppressNextUserMessagePersistence: params.suppressPromptPersistenceOnRetry === true,
disableTools,
allowEmptyAssistantReplyAsSilent: isSubagentAnnounceHandoff,
@@ -1244,6 +1253,8 @@ export function runAgentAttempt(params: {
deferTerminalLifecycle: params.deferTerminalLifecycle,
suppressNextUserMessagePersistence: params.suppressPromptPersistenceOnRetry === true,
userTurnTranscriptRecorder: params.userTurnTranscriptRecorder,
contextEngineLogicalTurnLease: params.contextEngineLogicalTurnLease,
onContextEngineTurnCandidate: params.onContextEngineTurnCandidate,
onUserMessagePersisted: params.onUserMessagePersisted,
onExecutionStarted: () => {
params.opts.onExecutionStarted?.();
@@ -1,4 +1,5 @@
import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
import { resolveSessionAuthProfileOverrideSource } from "../../config/sessions/auth-profile-override-provenance.js";
import type { SessionEntry } from "../../config/sessions/types.js";
import { emitAgentEvent } from "../../infra/agent-events.js";
import { createSubsystemLogger } from "../../logging/subsystem.js";
@@ -268,6 +269,10 @@ export async function runEmbeddedAgentAttempt(params: {
requestedRouteResolution: params.modelSelection.requestedRouteResolution,
agentDir,
fallbacksOverride: effectiveFallbacksOverride,
userLockedAuthProfileId:
resolveSessionAuthProfileOverrideSource(sessionEntryForAttempt) === "user"
? sessionEntryForAttempt?.authProfileOverride
: undefined,
...modelManifestContext,
},
identity: {
@@ -500,6 +505,8 @@ export async function runEmbeddedAgentAttempt(params: {
userTurnTranscriptRecorder.isBlocked() ||
(runOptions.isFallbackRetry && attemptLifecycleState.currentTurnUserMessagePersisted),
userTurnTranscriptRecorder,
contextEngineLogicalTurnLease: runOptions.contextEngineLogicalTurnLease,
onContextEngineTurnCandidate: runOptions.onContextEngineTurnCandidate,
onUserMessagePersisted: attemptLifecycleCallbacks.onUserMessagePersisted,
onLifecycleGenerationChanged: (nextLifecycleGeneration, nextAttribution) => {
lifecycleGeneration = nextLifecycleGeneration;
@@ -22,6 +22,14 @@ vi.mock("../model-fallback-attempt.js", () => ({
vi.mock("./compact.queued.js", () => ({ compactEmbeddedAgentSession: vi.fn() }));
vi.mock("./direct-compaction.js", () => ({
compactEmbeddedAgentSessionDirectOnce: vi.fn(async () => ({
ok: true,
compacted: false,
reason: "no-op",
})),
}));
vi.mock("../prepared-model-runtime.js", () => ({
acquireAgentRunPreparedModelRuntime: vi.fn(
async (input: {
@@ -35,6 +43,15 @@ vi.mock("../prepared-model-runtime.js", () => ({
agentId: input.agentId,
agentDir: input.agentDir,
workspaceDir: input.workspaceDir,
metadataSnapshot: {
plugins: [
{
id: "byteplus",
origin: "bundled",
providerAuthAliases: { "byteplus-plan": "byteplus" },
},
],
},
createStores: () => ({}),
},
release: vi.fn(),
@@ -44,8 +61,10 @@ vi.mock("../prepared-model-runtime.js", () => ({
import { runWithModelFallback } from "../model-fallback-runner.js";
import { compactEmbeddedAgentSessionDirect } from "./compact.js";
import { compactEmbeddedAgentSessionDirectOnce } from "./direct-compaction.js";
const runMock = vi.mocked(runWithModelFallback);
const compactOnceMock = vi.mocked(compactEmbeddedAgentSessionDirectOnce);
const baseParams = {
sessionId: "test-session",
@@ -76,6 +95,7 @@ function configWithFallbacks(fallbacks: string[]): OpenClawConfig {
describe("compactEmbeddedAgentSessionDirect abortSignal threading", () => {
beforeEach(() => {
runMock.mockClear();
compactOnceMock.mockClear();
});
it("forwards params.abortSignal to runWithModelFallback so terminal aborts during compaction short-circuit", async () => {
@@ -106,4 +126,46 @@ describe("compactEmbeddedAgentSessionDirect abortSignal threading", () => {
const passedParams = runMock.mock.calls[0]?.[0];
expect(passedParams?.abortSignal).toBeUndefined();
});
it.each([
{ source: "user" as const, expected: "anthropic:work" },
{ source: "auto" as const, expected: undefined },
])("forwards only $source auth profiles as user locks", async ({ source, expected }) => {
await compactEmbeddedAgentSessionDirect({
...baseParams,
config: configWithFallbacks(["anthropic/claude-haiku-4-5"]),
provider: "anthropic",
model: "claude-sonnet-4-6",
authProfileId: "anthropic:work",
authProfileIdSource: source,
});
expect(runMock.mock.calls[0]?.[0]?.userLockedAuthProfileId).toBe(expected);
});
it("preserves a user auth pin across BytePlus compaction fallback aliases", async () => {
await compactEmbeddedAgentSessionDirect({
...baseParams,
config: configWithFallbacks(["byteplus-plan/ark-code-latest"]),
provider: "byteplus",
model: "dola-seed-2-1-turbo-260628",
authProfileId: "byteplus:work",
authProfileIdSource: "user",
});
const fallbackRun = runMock.mock.calls[0]?.[0]?.run;
expect(fallbackRun).toBeTypeOf("function");
await fallbackRun?.("byteplus-plan", "ark-code-latest");
expect(compactOnceMock).toHaveBeenCalledWith(
expect.objectContaining({
provider: "byteplus-plan",
model: "ark-code-latest",
authProfileId: "byteplus:work",
authProfileIdSource: "user",
runtimeAuthPlan: undefined,
runtimePlan: undefined,
}),
);
});
});
@@ -819,6 +819,11 @@ export async function loadCompactHooksHarness(): Promise<{
vi.doMock("../../context-engine/registry.js", () => ({
resolveContextEngine: resolveContextEngineMock,
resolveContextEngineOwnerPluginId: vi.fn(() => "lossless-claw"),
resolveLogicalTurnContextEngines: async () => {
const engine = await resolveContextEngineMock();
const ref = { engine, registeredId: "legacy" };
return { configured: ref, configuredId: "legacy", fallback: ref };
},
}));
vi.doMock("../../process/command-queue.js", () => ({
+12 -3
View File
@@ -22,6 +22,7 @@ import { resolveModelCandidateChain } from "../model-fallback-candidates.js";
import { runWithModelFallback } from "../model-fallback-runner.js";
import { acquireAgentRunPreparedModelRuntime } from "../prepared-model-runtime.js";
import { resolveProjectKey } from "../project-memory-scope.js";
import { resolveProviderIdForAuth } from "../provider-auth-aliases.js";
import {
applyAgentRunSessionTargetIdentity,
resolveAgentRunSessionTarget,
@@ -263,6 +264,14 @@ export async function compactEmbeddedAgentSessionDirect(
const primaryProvider = resolvedCompactionTarget.provider ?? DEFAULT_PROVIDER;
const primaryModel = resolvedCompactionTarget.model ?? DEFAULT_MODEL;
const requestedPrimaryProvider = params.provider?.trim() || DEFAULT_PROVIDER;
const resolveAuthProvider = (provider: string) =>
resolveProviderIdForAuth(provider, {
config: params.config,
metadataSnapshot: preparedModelRuntime.metadataSnapshot,
});
const primaryAuthProviders = new Set(
[primaryProvider, requestedPrimaryProvider].map(resolveAuthProvider),
);
const fallbacksOverride = resolveCompactionFallbacksOverride(params);
const resolvedPrimaryCandidate = resolveModelCandidateChain({
cfg: params.config,
@@ -287,6 +296,8 @@ export async function compactEmbeddedAgentSessionDirect(
agentId: fallbackAgentId,
sessionId: params.sessionId,
sessionKey: fallbackSessionKey,
userLockedAuthProfileId:
params.authProfileIdSource === "user" ? params.authProfileId : undefined,
abortSignal: params.abortSignal,
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
@@ -308,9 +319,7 @@ export async function compactEmbeddedAgentSessionDirect(
provider === resolvedPrimaryCandidate?.provider &&
model === resolvedPrimaryCandidate.model;
const preservesPrimaryAuth =
isPrimaryCandidate ||
provider === primaryProvider ||
provider === requestedPrimaryProvider;
isPrimaryCandidate || primaryAuthProviders.has(resolveAuthProvider(provider));
const authProfileId = preservesPrimaryAuth ? params.authProfileId : undefined;
return await compactEmbeddedAgentSessionDirectOnce({
...params,
@@ -1,15 +1,26 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ContextEngineTurnAttemptFacts } from "../harness/context-engine-turn-attempt.js";
import type { EmbeddedAgentRunResult } from "./types.js";
type CandidateOptions = {
allowTransientCooldownProbe?: boolean;
isFinalFallbackAttempt?: boolean;
onContextEngineTurnCandidate?: (facts: ContextEngineTurnAttemptFacts) => void;
};
type FallbackRunnerParams = {
provider: string;
model: string;
resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
prepareCandidateChain?: (
candidates: ReadonlyArray<{
provider: string;
model: string;
routeOrigin: "requested" | "configured-fallback";
routeResolution: "raw";
}>,
) => Promise<void> | void;
prepareAgentHarnessRuntime?: (params: {
provider: string;
model: string;
@@ -43,6 +54,23 @@ type FallbackRunnerParams = {
const state = vi.hoisted(() => ({
runWithModelFallback: vi.fn(),
ensureSelectedAgentHarnessPlugin: vi.fn(async (_params: unknown) => undefined),
selectAgentHarness: vi.fn(({ provider }: { provider: string }) => ({
id: provider === "fallback-provider" ? "fallback-harness" : "primary-harness",
contextEngineHostCapabilities: [],
})),
discardedAttempts: [] as string[],
finalizedAttempts: [] as string[],
}));
vi.mock("../harness/context-engine-turn-attempt.js", () => ({
discardContextEngineTurnAttemptIntent: vi.fn(
({ facts }: { facts: ContextEngineTurnAttemptFacts }) => {
state.discardedAttempts.push(facts.sessionIdUsed);
},
),
finalizeAcceptedContextEngineTurn: vi.fn(async ({ facts }) => {
state.finalizedAttempts.push(facts.sessionIdUsed);
}),
}));
vi.mock("../model-fallback-runner.js", () => ({
@@ -54,38 +82,111 @@ vi.mock("../harness/runtime-plugin.js", () => ({
state.ensureSelectedAgentHarnessPlugin(params),
}));
vi.mock("../harness/selection.js", () => ({
selectAgentHarness: (params: { provider: string }) => state.selectAgentHarness(params),
}));
function makeResult(params: {
provider: string;
model: string;
classification?: "empty";
meta?: Partial<EmbeddedAgentRunResult["meta"]>;
}): EmbeddedAgentRunResult {
return {
payloads: params.classification ? [] : [{ text: "recovered" }],
meta: {
durationMs: 10,
aborted: false,
yielded: true,
providerStarted: true,
stopReason: "end_turn",
stopReason: "completed",
agentHarnessResultClassification: params.classification,
agentMeta: {
sessionId: "session-1",
provider: params.provider,
model: params.model,
},
...params.meta,
},
};
}
function recordTurnAttempt(
record: ((facts: ContextEngineTurnAttemptFacts) => void) | undefined,
label: string,
): void {
if (!record) {
throw new Error("expected context-engine turn candidate callback");
}
record({
boundary: {
admission: {
agentId: "main",
sessionId: label,
sessionKey: `agent:main:${label}`,
storePath: `/${label}.sqlite`,
generation: "generation-1",
entryId: `${label}-user`,
rawSeq: 1,
effectiveParentId: null,
activeMessagePosition: 0,
logicalTurnId: `${label}-turn`,
role: "user",
},
terminal: {
agentId: "main",
sessionId: label,
sessionKey: `agent:main:${label}`,
storePath: `/${label}.sqlite`,
generation: "generation-1",
entryId: `${label}-assistant`,
rawSeq: 2,
effectiveParentId: `${label}-user`,
activeMessagePosition: 1,
},
},
sessionIdUsed: label,
sessionFile: `${label}.jsonl`,
promptError: false,
aborted: false,
yieldAborted: false,
});
}
describe("runEmbeddedAgentEntry", () => {
beforeEach(() => {
state.ensureSelectedAgentHarnessPlugin.mockClear();
state.discardedAttempts.length = 0;
state.finalizedAttempts.length = 0;
state.ensureSelectedAgentHarnessPlugin.mockReset().mockResolvedValue(undefined);
state.selectAgentHarness
.mockReset()
.mockImplementation(({ provider }: { provider: string }) => ({
id: provider === "fallback-provider" ? "fallback-harness" : "primary-harness",
contextEngineHostCapabilities: [],
}));
state.runWithModelFallback
.mockReset()
.mockImplementation(async (params: FallbackRunnerParams) => {
await params.prepareCandidateChain?.([
{
provider: params.provider,
model: params.model,
routeOrigin: "requested",
routeResolution: "raw",
},
{
provider: "fallback-provider",
model: "fallback-model",
routeOrigin: "configured-fallback",
routeResolution: "raw",
},
]);
await params.prepareAgentHarnessRuntime?.({
provider: params.provider,
model: params.model,
agentHarnessRuntimeOverride: params.resolveAgentHarnessRuntimeOverride?.(
params.provider,
params.model,
),
});
const primaryResult = await params.run(params.provider, params.model, {
allowTransientCooldownProbe: true,
@@ -103,6 +204,10 @@ describe("runEmbeddedAgentEntry", () => {
await params.prepareAgentHarnessRuntime?.({
provider: fallbackProvider,
model: fallbackModel,
agentHarnessRuntimeOverride: params.resolveAgentHarnessRuntimeOverride?.(
fallbackProvider,
fallbackModel,
),
});
const result = await params.run(fallbackProvider, fallbackModel, {
isFinalFallbackAttempt: true,
@@ -133,6 +238,7 @@ describe("runEmbeddedAgentEntry", () => {
model: string;
isFallbackRetry: boolean;
}> = [];
const candidateLeases: object[] = [];
const reconciled: Array<{ provider: string; model: string }> = [];
const result = await runEmbeddedAgentEntry({
selection: { cfg, provider: "primary-provider", model: "primary-model" },
@@ -167,6 +273,7 @@ describe("runEmbeddedAgentEntry", () => {
},
runCandidate: async (provider, model, options) => {
candidateCalls.push({ provider, model, isFallbackRetry: options.isFallbackRetry });
candidateLeases.push(options.contextEngineLogicalTurnLease);
return makeResult({
provider,
model,
@@ -176,7 +283,7 @@ describe("runEmbeddedAgentEntry", () => {
});
await result.settleSessionOverride();
await result.settleSessionOverride();
return { result, candidateCalls, reconciled };
return { result, candidateCalls, candidateLeases, reconciled };
};
const channel = await runMode("channel-delivery");
@@ -188,12 +295,91 @@ describe("runEmbeddedAgentEntry", () => {
expect(channel.result.model).toBe("fallback-model");
expect(channel.result.attempts).toEqual(command.result.attempts);
expect(channel.result.terminal).toEqual(command.result.terminal);
expect(channel.candidateLeases[0]).toBe(channel.candidateLeases[1]);
expect(state.selectAgentHarness).toHaveBeenCalledWith(
expect.objectContaining({
provider: "fallback-provider",
modelId: "fallback-model",
}),
);
expect(channel.reconciled).toEqual(command.reconciled);
expect(channel.reconciled).toEqual([
{ provider: "fallback-provider", model: "fallback-model" },
]);
});
it("preflights caller-resolved CLI hosts instead of the model harness", async () => {
const resolveContextEngineHost = vi.fn((provider: string) => ({
id: `cli:${provider}`,
label: `CLI backend "${provider}"`,
capabilities: [],
}));
const { runEmbeddedAgentEntry } = await import("./run-entry.js");
await runEmbeddedAgentEntry({
selection: { cfg: {}, provider: "primary-provider", model: "primary-model" },
identity: { runId: "cli-host-preflight", agentId: "main", sessionId: "session-1" },
harness: {
workspaceDir: "/tmp/workspace",
preparation: { kind: "direct" },
resolveRuntimeOverride: () => undefined,
resolveContextEngineHost,
},
behavior: { kind: "command-rpc", hasCommittedSideEffect: () => false },
sessionOverride: { kind: "preserve" },
runCandidate: async (provider, model) =>
makeResult({
provider,
model,
classification: provider === "primary-provider" ? "empty" : undefined,
}),
});
expect(resolveContextEngineHost).toHaveBeenCalledWith("primary-provider", "primary-model");
expect(resolveContextEngineHost).toHaveBeenCalledWith("fallback-provider", "fallback-model");
expect(state.selectAgentHarness).not.toHaveBeenCalled();
});
it("registers lazy harness plugins before selecting preflight hosts", async () => {
const events: string[] = [];
state.ensureSelectedAgentHarnessPlugin.mockImplementation(async (params: unknown) => {
events.push(`ensure:${(params as { provider: string }).provider}`);
});
state.selectAgentHarness.mockImplementation(({ provider }: { provider: string }) => {
events.push(`select:${provider}`);
return {
id: `${provider}-harness`,
contextEngineHostCapabilities: [],
};
});
const { runEmbeddedAgentEntry } = await import("./run-entry.js");
await runEmbeddedAgentEntry({
selection: { cfg: {}, provider: "primary-provider", model: "primary-model" },
identity: { runId: "lazy-plugin-preflight", agentId: "main", sessionId: "session-1" },
harness: {
workspaceDir: "/tmp/workspace",
preparation: { kind: "direct" },
resolveRuntimeOverride: (provider) => `${provider}-harness`,
},
behavior: { kind: "command-rpc", hasCommittedSideEffect: () => false },
sessionOverride: { kind: "preserve" },
runCandidate: async (provider, model) =>
makeResult({
provider,
model,
classification: provider === "primary-provider" ? "empty" : undefined,
}),
});
expect(events).toEqual([
"ensure:primary-provider",
"select:primary-provider",
"ensure:fallback-provider",
"select:fallback-provider",
]);
});
it("leaves maintenance fallback classification to thrown candidate errors", async () => {
state.runWithModelFallback.mockImplementationOnce(async (params: FallbackRunnerParams) => {
expect(params.classifyResult).toBeUndefined();
@@ -224,6 +410,201 @@ describe("runEmbeddedAgentEntry", () => {
expect(result.result.payloads).toEqual([{ text: "recovered" }]);
});
it("finalizes only the accepted fallback candidate after its attempt releases ownership", async () => {
let primaryReleased = false;
let fallbackReleased = false;
const { runEmbeddedAgentEntry } = await import("./run-entry.js");
await runEmbeddedAgentEntry({
selection: { cfg: {}, provider: "primary-provider", model: "primary-model" },
identity: { runId: "settle-winner", agentId: "main", sessionId: "session-1" },
harness: {
workspaceDir: "/tmp/workspace",
preparation: { kind: "direct" },
resolveRuntimeOverride: () => undefined,
},
behavior: { kind: "command-rpc", hasCommittedSideEffect: () => false },
sessionOverride: { kind: "preserve" },
runCandidate: async (provider, model, options) => {
const label = provider === "primary-provider" ? "primary" : "fallback";
recordTurnAttempt(options.onContextEngineTurnCandidate, label);
if (label === "primary") {
primaryReleased = true;
} else {
fallbackReleased = true;
}
return makeResult({
provider,
model,
classification: label === "primary" ? "empty" : undefined,
});
},
});
expect(primaryReleased).toBe(true);
expect(fallbackReleased).toBe(true);
expect(state.finalizedAttempts).toEqual(["fallback"]);
});
it("accepts an empty result after a committed side effect and finalizes it once", async () => {
state.runWithModelFallback.mockImplementationOnce(async (params: FallbackRunnerParams) => {
const result = await params.run(params.provider, params.model);
expect(
params.classifyResult?.({
result,
provider: params.provider,
model: params.model,
attempt: 1,
total: 1,
}),
).toBeUndefined();
return {
outcome: "completed" as const,
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
const { runEmbeddedAgentEntry } = await import("./run-entry.js");
await runEmbeddedAgentEntry({
selection: { cfg: {}, provider: "provider", model: "model" },
identity: { runId: "settle-side-effect", agentId: "main", sessionId: "session-1" },
harness: {
workspaceDir: "/tmp/workspace",
preparation: { kind: "direct" },
resolveRuntimeOverride: () => undefined,
},
behavior: { kind: "command-rpc", hasCommittedSideEffect: () => true },
sessionOverride: { kind: "preserve" },
runCandidate: async (provider, model, options) => {
recordTurnAttempt(options.onContextEngineTurnCandidate, "candidate");
return makeResult({ provider, model, classification: "empty" });
},
});
expect(state.finalizedAttempts).toEqual(["candidate"]);
});
it("does not finalize any candidate when fallback is exhausted", async () => {
state.runWithModelFallback.mockImplementationOnce(async (params: FallbackRunnerParams) => {
const preferredResult = await params.run(params.provider, params.model);
const latestResult = await params.run("fallback-provider", "fallback-model");
return {
outcome: "exhausted" as const,
result: params.mergeExhaustedResult?.({ latestResult, preferredResult }) ?? latestResult,
provider: "fallback-provider",
model: "fallback-model",
attempts: [],
};
});
const { runEmbeddedAgentEntry } = await import("./run-entry.js");
await runEmbeddedAgentEntry({
selection: { cfg: {}, provider: "provider", model: "model" },
identity: { runId: "settle-exhausted", agentId: "main", sessionId: "session-1" },
harness: {
workspaceDir: "/tmp/workspace",
preparation: { kind: "direct" },
resolveRuntimeOverride: () => undefined,
},
behavior: { kind: "command-rpc", hasCommittedSideEffect: () => false },
sessionOverride: { kind: "preserve" },
runCandidate: async (provider, model, options) => {
recordTurnAttempt(options.onContextEngineTurnCandidate, provider);
return makeResult({ provider, model, classification: "empty" });
},
});
expect(state.finalizedAttempts).toEqual([]);
expect(state.discardedAttempts).toEqual(["fallback-provider"]);
});
it.each([
{
label: "yielded",
meta: { yielded: true, livenessState: "paused" as const, stopReason: "end_turn" },
},
{ label: "aborted", meta: { aborted: true, stopReason: "error" } },
{ label: "timed out", meta: { timeoutPhase: "provider" as const, stopReason: "timeout" } },
{
label: "errored",
meta: {
error: { kind: "retry_limit" as const, message: "provider failed" },
stopReason: "error",
},
},
])("does not finalize a $label candidate", async ({ meta }) => {
state.runWithModelFallback.mockImplementationOnce(async (params: FallbackRunnerParams) => {
const result = await params.run(params.provider, params.model);
return {
outcome: "completed" as const,
result,
provider: params.provider,
model: params.model,
attempts: [],
};
});
const { runEmbeddedAgentEntry } = await import("./run-entry.js");
await runEmbeddedAgentEntry({
selection: { cfg: {}, provider: "provider", model: "model" },
identity: { runId: "settle-non-terminal", agentId: "main", sessionId: "session-1" },
harness: {
workspaceDir: "/tmp/workspace",
preparation: { kind: "direct" },
resolveRuntimeOverride: () => undefined,
},
behavior: { kind: "command-rpc", hasCommittedSideEffect: () => true },
sessionOverride: { kind: "preserve" },
runCandidate: async (provider, model, options) => {
recordTurnAttempt(options.onContextEngineTurnCandidate, "candidate");
return makeResult({ provider, model, meta });
},
});
expect(state.finalizedAttempts).toEqual([]);
expect(state.discardedAttempts).toEqual(["candidate"]);
});
it("does not finalize a candidate when classification throws", async () => {
const classificationError = new Error("classification failed");
state.runWithModelFallback.mockImplementationOnce(async (params: FallbackRunnerParams) => {
const result = await params.run(params.provider, params.model);
params.classifyResult?.({
result,
provider: params.provider,
model: params.model,
attempt: 1,
total: 1,
});
throw classificationError;
});
const { runEmbeddedAgentEntry } = await import("./run-entry.js");
await expect(
runEmbeddedAgentEntry({
selection: { cfg: {}, provider: "provider", model: "model" },
identity: { runId: "settle-classifier-throw", agentId: "main", sessionId: "session-1" },
harness: {
workspaceDir: "/tmp/workspace",
preparation: { kind: "direct" },
resolveRuntimeOverride: () => undefined,
},
behavior: {
kind: "channel-delivery",
readDeliveryEvidence: () => {
throw classificationError;
},
},
sessionOverride: { kind: "preserve" },
runCandidate: async (provider, model, options) => {
recordTurnAttempt(options.onContextEngineTurnCandidate, "candidate");
return makeResult({ provider, model, classification: "empty" });
},
}),
).rejects.toBe(classificationError);
expect(state.finalizedAttempts).toEqual([]);
expect(state.discardedAttempts).toEqual(["candidate"]);
});
it("does not replay a thrown channel-delivery attempt that already delivered its reply (#113788)", async () => {
const failure = new Error("insufficient quota");
state.runWithModelFallback.mockImplementationOnce(async (params: FallbackRunnerParams) => {
+265 -117
View File
@@ -1,11 +1,22 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ContextEngineHostSupport } from "../../context-engine/host-compat.js";
import { requireActivePluginRegistry } from "../../plugins/runtime.js";
import { buildAgentRunTerminalOutcome } from "../agent-run-terminal-outcome.js";
import {
buildAgentRunTerminalReplySnapshot,
normalizeAgentRunTerminalReplySnapshot,
} from "../agent-run-terminal-reply.js";
import {
createContextEngineLogicalTurnLease,
type ContextEngineLogicalTurnLease,
} from "../harness/context-engine-logical-turn.js";
import {
discardContextEngineTurnAttemptIntent,
finalizeAcceptedContextEngineTurn,
type ContextEngineTurnAttemptFacts,
} from "../harness/context-engine-turn-attempt.js";
import { ensureSelectedAgentHarnessPlugin } from "../harness/runtime-plugin.js";
import { selectAgentHarness } from "../harness/selection.js";
import type { ModelFallbackResultClassification } from "../model-fallback-attempt.js";
import type { ModelFallbackStepFields } from "../model-fallback-observation.js";
import { runWithModelFallback } from "../model-fallback-runner.js";
@@ -22,6 +33,13 @@ type RunEntryCandidateOptions = {
allowTransientCooldownProbe?: boolean;
isFinalFallbackAttempt?: boolean;
isFallbackRetry: boolean;
contextEngineLogicalTurnLease: ContextEngineLogicalTurnLease;
onContextEngineTurnCandidate: (facts: ContextEngineTurnAttemptFacts) => void;
};
type RunEntryCandidate<T> = {
result: T;
turnAttempt?: ContextEngineTurnAttemptFacts;
};
type RunEntryHarnessPreparation =
@@ -78,6 +96,7 @@ type EmbeddedAgentRunEntryParams<T extends EmbeddedAgentRunResult> = {
requestedRouteResolution?: ModelFallbackRouteResolution;
fallbacksOverride?: string[];
agentDir?: string;
userLockedAuthProfileId?: string;
} & ModelManifestNormalizationContext;
identity: {
runId: string;
@@ -91,6 +110,10 @@ type EmbeddedAgentRunEntryParams<T extends EmbeddedAgentRunResult> = {
sessionKey?: string;
preparation: RunEntryHarnessPreparation;
resolveRuntimeOverride: (provider: string, model: string) => string | undefined;
resolveContextEngineHost?: (
provider: string,
model: string,
) => ContextEngineHostSupport | undefined;
};
behavior: RunEntryBehavior;
sessionOverride: RunEntrySessionOverride;
@@ -144,6 +167,24 @@ function resolveTerminalStatus(params: {
return "ok";
}
function canAdvanceContextEngineTurn(params: {
result: EmbeddedAgentRunResult;
fallbackOutcome: "completed" | "exhausted";
terminal: EmbeddedAgentRunEntryTerminal;
}): boolean {
const meta = params.result.meta;
return (
params.fallbackOutcome === "completed" &&
params.terminal.outcome.status === "ok" &&
meta.yielded !== true &&
meta.aborted !== true &&
meta.error === undefined &&
meta.timeoutPhase === undefined &&
meta.stopReason !== "error" &&
meta.stopReason !== "timeout"
);
}
function buildTerminal(params: {
result: EmbeddedAgentRunResult;
fallbackExhausted: boolean;
@@ -200,15 +241,54 @@ function buildTerminal(params: {
return { outcome, metadata };
}
/** Runs a fallback candidate chain and prepares its shared terminal settlement state. */
/** Runs one logical turn across model candidates and advances only the accepted winner. */
export async function runEmbeddedAgentEntry<T extends EmbeddedAgentRunResult>(
params: EmbeddedAgentRunEntryParams<T>,
): Promise<EmbeddedAgentRunEntryResult<T>> {
const contextEngineLogicalTurnLease = await createContextEngineLogicalTurnLease({
config: params.selection.cfg,
agentDir: params.selection.agentDir,
workspaceDir: params.harness.workspaceDir,
});
let unsettledContextEngineTurnAttempt: ContextEngineTurnAttemptFacts | undefined;
let candidateIndex = 0;
const committedSideEffect =
params.behavior.kind === "command-rpc" ? params.behavior.hasCommittedSideEffect : undefined;
const readChannelDeliveryEvidence =
params.behavior.kind === "channel-delivery" ? params.behavior.readDeliveryEvidence : undefined;
const preparedHarnessRuntimes = new Set<string>();
const prepareHarnessRuntime = async (candidate: {
provider: string;
model: string;
agentHarnessRuntimeOverride?: string;
}) => {
const key = [
candidate.provider,
candidate.model,
candidate.agentHarnessRuntimeOverride ?? "",
].join("\0");
if (preparedHarnessRuntimes.has(key)) {
return;
}
const prepare = () =>
ensureSelectedAgentHarnessPlugin({
config: params.selection.cfg,
provider: candidate.provider,
modelId: candidate.model,
agentId: params.identity.agentId,
sessionKey: params.harness.sessionKey,
agentHarnessId: candidate.agentHarnessRuntimeOverride,
agentHarnessRuntimeOverride: candidate.agentHarnessRuntimeOverride,
workspaceDir: params.harness.workspaceDir,
pluginRegistry: requireActivePluginRegistry(),
});
if (params.harness.preparation.kind === "measured") {
await params.harness.preparation.run(prepare);
} else {
await prepare();
}
preparedHarnessRuntimes.add(key);
};
// Thrown candidate errors skip result classification, so without an error-path
// backstop the loop advances to the next candidate even when the attempt already
// delivered its reply, producing a duplicate visible answer (#113788). Consult the
@@ -222,128 +302,196 @@ export async function runEmbeddedAgentEntry<T extends EmbeddedAgentRunResult>(
return !evidence.hasDirectlySentBlockReply && !evidence.hasBlockReplyPipelineOutput;
}
: undefined;
const fallbackResult = await runWithModelFallback<T>({
...params.selection,
...params.identity,
abortSignal: params.abortSignal,
resolveAgentHarnessRuntimeOverride: params.harness.resolveRuntimeOverride,
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
const prepare = () =>
ensureSelectedAgentHarnessPlugin({
config: params.selection.cfg,
provider,
modelId: model,
agentId: params.identity.agentId,
sessionKey: params.harness.sessionKey,
agentHarnessId: agentHarnessRuntimeOverride,
agentHarnessRuntimeOverride,
workspaceDir: params.harness.workspaceDir,
pluginRegistry: requireActivePluginRegistry(),
});
if (params.harness.preparation.kind === "measured") {
await params.harness.preparation.run(prepare);
} else {
await prepare();
}
},
onFallbackStep: params.onFallbackStep,
...(params.behavior.kind === "maintenance"
? {}
: {
classifyResult: ({
result,
provider,
model,
}: {
result: T;
provider: string;
model: string;
}) => {
const deliveryEvidence =
params.behavior.kind === "channel-delivery"
? params.behavior.readDeliveryEvidence()
: undefined;
const classification = classifyEmbeddedAgentRunResultForModelFallback({
result,
try {
const fallbackResult = await runWithModelFallback<RunEntryCandidate<T>>({
...params.selection,
...params.identity,
abortSignal: params.abortSignal,
resolveAgentHarnessRuntimeOverride: params.harness.resolveRuntimeOverride,
prepareCandidateChain: async (candidates) => {
for (const candidate of candidates) {
try {
const agentHarnessRuntimeOverride = params.harness.resolveRuntimeOverride(
candidate.provider,
candidate.model,
);
await prepareHarnessRuntime({
provider: candidate.provider,
model: candidate.model,
...(agentHarnessRuntimeOverride ? { agentHarnessRuntimeOverride } : {}),
});
const resolvedHost = params.harness.resolveContextEngineHost?.(
candidate.provider,
candidate.model,
);
const host =
resolvedHost ??
(() => {
const harness = selectAgentHarness({
provider: candidate.provider,
modelId: candidate.model,
config: params.selection.cfg,
agentId: params.identity.agentId,
sessionKey: params.harness.sessionKey,
agentHarnessRuntimeOverride,
});
return {
id: `agent-harness:${harness.id}`,
label: `agent harness "${harness.id}"`,
capabilities: harness.contextEngineHostCapabilities ?? [],
};
})();
contextEngineLogicalTurnLease.selectForHost({
host,
operation: "agent-run",
requiresDurableCommit: false,
hasAdmissionFence: false,
});
} catch {
contextEngineLogicalTurnLease.degradeBeforeStart(
"a model fallback candidate harness could not be validated before dispatch",
);
return;
}
}
},
prepareAgentHarnessRuntime: prepareHarnessRuntime,
onFallbackStep: params.onFallbackStep,
...(params.behavior.kind === "maintenance"
? {}
: {
classifyResult: ({
result: candidate,
provider,
model,
...deliveryEvidence,
});
const effectiveClassification =
params.behavior.kind === "followup-delivery"
? preserveFollowupResultForDelivery(classification)
: classification;
return effectiveClassification && committedSideEffect?.()
? undefined
: effectiveClassification;
},
}),
...(canFallbackAfterError ? { canFallbackAfterError } : {}),
...(params.behavior.kind === "maintenance"
? {}
: {
mergeExhaustedResult: ({
latestResult,
preferredResult,
}: {
latestResult: T;
preferredResult: T;
}) =>
mergeEmbeddedAgentRunResultForModelFallbackExhaustion({
}: {
result: RunEntryCandidate<T>;
provider: string;
model: string;
}) => {
const deliveryEvidence =
params.behavior.kind === "channel-delivery"
? params.behavior.readDeliveryEvidence()
: undefined;
const classification = classifyEmbeddedAgentRunResultForModelFallback({
result: candidate.result,
provider,
model,
...deliveryEvidence,
});
const effectiveClassification =
params.behavior.kind === "followup-delivery"
? preserveFollowupResultForDelivery(classification)
: classification;
return effectiveClassification && committedSideEffect?.()
? undefined
: effectiveClassification;
},
}),
...(canFallbackAfterError ? { canFallbackAfterError } : {}),
...(params.behavior.kind === "maintenance"
? {}
: {
mergeExhaustedResult: ({
latestResult,
preferredResult,
}) as T,
}),
run: async (provider, model, options) => {
const isFallbackRetry = candidateIndex > 0;
candidateIndex += 1;
return params.runCandidate(provider, model, {
allowTransientCooldownProbe: options?.allowTransientCooldownProbe,
isFinalFallbackAttempt: options?.isFinalFallbackAttempt,
isFallbackRetry,
});
},
});
const abortFields =
params.behavior.kind === "command-rpc"
? resolveAgentRunAbortLifecycleFields(params.abortSignal)
: {};
const result =
abortFields.aborted === true
? ({
...fallbackResult.result,
meta: {
...fallbackResult.result.meta,
...abortFields,
}: {
latestResult: RunEntryCandidate<T>;
preferredResult: RunEntryCandidate<T>;
}) => ({
result: mergeEmbeddedAgentRunResultForModelFallbackExhaustion({
latestResult: latestResult.result,
preferredResult: preferredResult.result,
}) as T,
turnAttempt: latestResult.turnAttempt,
}),
}),
run: async (provider, model, options) => {
const isFallbackRetry = candidateIndex > 0;
candidateIndex += 1;
let contextEngineTurnCandidate: ContextEngineTurnAttemptFacts | undefined;
const result = await params.runCandidate(provider, model, {
allowTransientCooldownProbe: options?.allowTransientCooldownProbe,
isFinalFallbackAttempt: options?.isFinalFallbackAttempt,
isFallbackRetry,
contextEngineLogicalTurnLease,
onContextEngineTurnCandidate: (facts) => {
contextEngineTurnCandidate = facts;
unsettledContextEngineTurnAttempt = facts;
},
} as T)
: fallbackResult.result;
const settledResult = {
...fallbackResult,
outcome:
fallbackResult.outcome === "exhausted" ? ("exhausted" as const) : ("completed" as const),
result,
};
const terminal = buildTerminal({
result,
fallbackExhausted: settledResult.outcome === "exhausted",
behavior: params.behavior,
});
let sessionOverrideSettled = false;
const settleSessionOverride = async () => {
if (sessionOverrideSettled) {
return;
});
return { result, turnAttempt: contextEngineTurnCandidate };
},
});
const abortFields =
params.behavior.kind === "command-rpc"
? resolveAgentRunAbortLifecycleFields(params.abortSignal)
: {};
const result =
abortFields.aborted === true
? ({
...fallbackResult.result.result,
meta: {
...fallbackResult.result.result.meta,
...abortFields,
},
} as T)
: fallbackResult.result.result;
const settledResult = {
...fallbackResult,
outcome:
fallbackResult.outcome === "exhausted" ? ("exhausted" as const) : ("completed" as const),
result,
};
const terminal = buildTerminal({
result,
fallbackExhausted: settledResult.outcome === "exhausted",
behavior: params.behavior,
});
if (fallbackResult.result.turnAttempt) {
if (
canAdvanceContextEngineTurn({
result,
fallbackOutcome: settledResult.outcome,
terminal,
})
) {
await finalizeAcceptedContextEngineTurn({
facts: fallbackResult.result.turnAttempt,
lease: contextEngineLogicalTurnLease,
});
} else {
discardContextEngineTurnAttemptIntent({
facts: fallbackResult.result.turnAttempt,
lease: contextEngineLogicalTurnLease,
});
}
unsettledContextEngineTurnAttempt = undefined;
}
sessionOverrideSettled = true;
if (
settledResult.outcome === "completed" &&
params.sessionOverride.kind === "reconcile-completed"
) {
await params.sessionOverride.reconcile({
provider: settledResult.provider,
model: settledResult.model,
let sessionOverrideSettled = false;
const settleSessionOverride = async () => {
if (sessionOverrideSettled) {
return;
}
sessionOverrideSettled = true;
if (
settledResult.outcome === "completed" &&
params.sessionOverride.kind === "reconcile-completed"
) {
await params.sessionOverride.reconcile({
provider: settledResult.provider,
model: settledResult.model,
});
}
};
return { ...settledResult, terminal, settleSessionOverride };
} finally {
if (unsettledContextEngineTurnAttempt) {
discardContextEngineTurnAttemptIntent({
facts: unsettledContextEngineTurnAttempt,
lease: contextEngineLogicalTurnLease,
});
}
};
return { ...settledResult, terminal, settleSessionOverride };
await contextEngineLogicalTurnLease.dispose();
}
}
+50 -27
View File
@@ -1,10 +1,6 @@
/** Prepared embedded-agent loop and cleanup. */
import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js";
import { ensureContextEnginesInitialized } from "../../context-engine/init.js";
import {
resolveContextEngine,
resolveContextEngineOwnerPluginId,
} from "../../context-engine/registry.js";
import { resolveContextEngineOwnerPluginId } from "../../context-engine/registry.js";
import { buildContextEngineRuntimeSettings } from "../../context-engine/runtime-settings.js";
import { formatErrorMessage } from "../../infra/errors.js";
import {
@@ -13,8 +9,14 @@ import {
} from "../agent-bundle-mcp-tools.js";
import { resolveSessionAgentIds } from "../agent-scope.js";
import type { ToolOutcomeObservation } from "../agent-tools.before-tool-call.js";
import { isHeartbeatLifecycleRunKind } from "../bootstrap-mode.js";
import type { FailoverReason } from "../embedded-agent-helpers.js";
import { isStrictAgenticExecutionContractActive } from "../execution-contract.js";
import {
createContextEngineLogicalTurnLease,
selectContextEngineForTranscriptHost,
} from "../harness/context-engine-logical-turn.js";
import { drainPendingContextEngineTurnsBeforeRun } from "../harness/context-engine-turn-attempt.js";
import type { McpAppChannelView } from "../mcp-ui-resource.js";
import { runAgentCleanupStep } from "../run-cleanup-timeout.js";
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
@@ -264,19 +266,38 @@ export async function runPreparedEmbeddedLoop(
harnessOwnsTransport: () => preparedRuntime.snapshot().pluginHarnessOwnsTransport,
getApiKeyInfo,
});
// Resolve the context engine once and reuse across retries to avoid
// repeated initialization/connection overhead per attempt.
ensureContextEnginesInitialized();
const contextEngine = await measureEmbeddedAgentPreparation(
"context-engine",
() =>
resolveContextEngine(params.config, {
agentDir,
workspaceDir: resolvedWorkspace,
}),
{ config: params.config },
);
const resolveContextEnginePluginId = () => resolveContextEngineOwnerPluginId(contextEngine);
const ownsContextEngineLogicalTurnLease = params.contextEngineLogicalTurnLease === undefined;
const contextEngineLogicalTurnLease =
params.contextEngineLogicalTurnLease ??
(await measureEmbeddedAgentPreparation(
"context-engine",
() =>
createContextEngineLogicalTurnLease({
config: params.config,
agentDir,
workspaceDir: resolvedWorkspace,
}),
{ config: params.config },
));
selectContextEngineForTranscriptHost({
lease: contextEngineLogicalTurnLease,
host: {
id: `agent-harness:${agentHarness.id}`,
label: `agent harness "${agentHarness.id}"`,
capabilities: agentHarness.contextEngineHostCapabilities ?? [],
},
operation: "agent-run",
recorder: params.userTurnTranscriptRecorder,
});
await drainPendingContextEngineTurnsBeforeRun({
admission: params.userTurnTranscriptRecorder?.getAdmissionReceipt(),
isHeartbeat: isHeartbeatLifecycleRunKind(params.bootstrapContextRunKind),
lease: contextEngineLogicalTurnLease,
});
const contextEngine = contextEngineLogicalTurnLease.begin().engine;
const resolveContextEnginePluginId = () =>
contextEngineLogicalTurnLease.effectiveEnginePluginId ??
resolveContextEngineOwnerPluginId(contextEngine);
startupStages.mark("context-engine");
notifyExecutionPhase("context_engine", { provider, model: modelId });
try {
@@ -643,15 +664,17 @@ export async function runPreparedEmbeddedLoop(
forgetPromptBuildDrainCacheForRun(params.runId);
clearProviderPromptState(params.runId);
stopRuntimeAuthRefreshTimer();
await runAgentCleanupStep({
runId: params.runId,
sessionId: params.sessionId,
step: "context-engine-dispose",
log,
cleanup: async () => {
await contextEngine.dispose?.();
},
});
if (ownsContextEngineLogicalTurnLease) {
await runAgentCleanupStep({
runId: params.runId,
sessionId: params.sessionId,
step: "context-engine-dispose",
log,
cleanup: async () => {
await contextEngineLogicalTurnLease.dispose();
},
});
}
if (params.cleanupBundleMcpOnRunEnd === true) {
await runAgentCleanupStep({
runId: params.runId,
@@ -1983,8 +1983,22 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
it("waits for asynchronous user persistence before retrying a missing terminal turn", async () => {
mockedClassifyFailoverReason.mockReturnValue(null);
const persistedMessage = { role: "user" as const, content: "test prompt", timestamp: 1 };
const admission = {
agentId: "main",
sessionId: overflowBaseRunParams.sessionId,
sessionKey: overflowBaseRunParams.sessionKey,
storePath: "/tmp/openclaw-transcript.jsonl",
generation: "generation-1",
entryId: "msg-user-delayed",
rawSeq: 1,
effectiveParentId: null,
activeMessagePosition: 0,
logicalTurnId: "run-missing-assistant-delayed-persistence",
role: "user" as const,
};
let resolvePersistApproved:
| ((result: {
admission: typeof admission;
sessionFile: string;
sessionEntry: undefined;
messageId: string;
@@ -1995,6 +2009,7 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
const persistApproved = vi.fn(
() =>
new Promise<{
admission: typeof admission;
sessionFile: string;
sessionEntry: undefined;
messageId: string;
@@ -2020,6 +2035,7 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
userTurnTranscriptRecorder: {
message: persistedMessage,
resolveMessage: vi.fn(async () => persistedMessage),
getAdmissionReceipt: () => admission,
markRuntimePersistencePending: vi.fn((pending) => {
pendingPersistence = pending;
}),
@@ -2044,6 +2060,7 @@ describe("runEmbeddedAgent incomplete-turn safety", () => {
expect(mockedRunEmbeddedAttempt).toHaveBeenCalledTimes(1);
resolvePersistApproved?.({
admission,
sessionFile: "/tmp/openclaw-transcript.jsonl",
sessionEntry: undefined,
messageId: "msg-user-delayed",
@@ -760,6 +760,11 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
vi.doMock("../../context-engine/registry.js", () => ({
resolveContextEngine: mockedResolveContextEngine,
resolveContextEngineOwnerPluginId: mockedResolveContextEngineOwnerPluginId,
resolveLogicalTurnContextEngines: async () => {
const engine = await mockedResolveContextEngine();
const ref = { engine, registeredId: "legacy" };
return { configured: ref, configuredId: "legacy", fallback: ref };
},
}));
vi.doMock("../harness/runtime-plugin.js", () => ({
@@ -23,6 +23,20 @@ const BASE_RUN_PARAMS = {
runId: "run-1",
} satisfies PreparedEmbeddedRunInput["runParams"];
const TEST_ADMISSION = {
agentId: "main",
sessionId: BASE_RUN_PARAMS.sessionId,
sessionKey: BASE_RUN_PARAMS.sessionKey,
storePath: BASE_RUN_PARAMS.sessionTarget.storePath,
generation: "test-generation",
entryId: "msg-user-1",
rawSeq: 1,
effectiveParentId: null,
activeMessagePosition: 0,
logicalTurnId: "test-logical-turn",
role: "user" as const,
};
function makeUserMessage(content = BASE_RUN_PARAMS.prompt) {
return { role: "user" as const, content, timestamp: 1 };
}
@@ -34,6 +48,7 @@ function createRecorder(
return {
message: makeUserMessage(),
resolveMessage: vi.fn(async () => makeUserMessage()),
getAdmissionReceipt: () => TEST_ADMISSION,
markRuntimePersistencePending: vi.fn((pending) => {
pendingPersistence = pending;
}),
@@ -65,6 +80,7 @@ describe("embedded run session prompt state", () => {
it("records canonical runtime persistence without mutating recorder lifecycle state", async () => {
const persistedMessage = makeUserMessage();
const persistApproved = vi.fn(async () => ({
admission: TEST_ADMISSION,
sessionFile: BASE_RUN_PARAMS.sessionFile,
sessionEntry: undefined,
messageId: "msg-user-1",
@@ -125,6 +141,7 @@ describe("embedded run session prompt state", () => {
};
const persistApproved = vi.fn(async () => undefined);
const persistBlocked = vi.fn(async () => ({
admission: TEST_ADMISSION,
sessionFile: BASE_RUN_PARAMS.sessionFile,
sessionEntry: undefined,
messageId: "msg-user-blocked",
@@ -171,6 +188,7 @@ describe("embedded run session prompt state", () => {
const persistedMessage = makeUserMessage();
let resolvePersistence:
| ((result: {
admission: typeof TEST_ADMISSION;
sessionFile: string;
sessionEntry: undefined;
messageId: string;
@@ -180,6 +198,7 @@ describe("embedded run session prompt state", () => {
const persistApproved = vi.fn(
() =>
new Promise<{
admission: typeof TEST_ADMISSION;
sessionFile: string;
sessionEntry: undefined;
messageId: string;
@@ -207,6 +226,7 @@ describe("embedded run session prompt state", () => {
expect(state.suppressNextUserMessagePersistence).toBe(false);
resolvePersistence?.({
admission: TEST_ADMISSION,
sessionFile: BASE_RUN_PARAMS.sessionFile,
sessionEntry: undefined,
messageId: "msg-user-delayed",
@@ -1,3 +1,4 @@
import { readActiveTranscriptEntryAnchor } from "../../../config/sessions/session-accessor.js";
/**
* Runs post-stream context-engine, transcript, cache, and lifecycle work.
*/
@@ -97,46 +98,90 @@ export async function completeEmbeddedAttemptAfterTurn(
activeAgentId: runtime.sessionAgentId,
contextEnginePluginId: runtime.resolveActiveContextEnginePluginId(),
});
await finalizeAttemptContextEngineTurn({
contextEngine: activeContextEngine,
promptError: Boolean(state.promptError),
aborted: lifecycleState.aborted,
yieldAborted: state.yieldAborted,
sessionIdUsed,
sessionKey: attempt.sessionKey,
sessionTarget: attempt.sessionTarget,
sessionFile: attempt.sessionFile,
messagesSnapshot: state.messagesSnapshot,
prePromptMessageCount: state.contextEngineAfterTurnCheckpoint ?? state.prePromptMessageCount,
tokenBudget: attempt.contextTokenBudget,
runtimeContext: afterTurnRuntimeContext,
contextEngineHostSupport: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST,
providerId: attempt.provider,
requestedModelId: attempt.requestedModelId,
modelId: attempt.modelId,
fallbackReason: attempt.fallbackReason,
degradedReason: attempt.degradedReason,
runMaintenance: async (contextParams) =>
await runContextEngineMaintenance({
contextEngine: contextParams.contextEngine as never,
sessionId: contextParams.sessionId,
sessionKey: contextParams.sessionKey,
sessionTarget: contextParams.sessionTarget,
sessionFile: contextParams.sessionFile,
reason: contextParams.reason,
sessionManager: contextParams.sessionManager as never,
withSessionManagerRewriteLock: async (operation) =>
await input.withOwnedSessionWriteLock(operation),
runtimeContext: contextParams.runtimeContext,
runtimeSettings: contextParams.runtimeSettings,
const finalizeTurn = async (transcript: {
messagesSnapshot: AgentMessage[];
prePromptMessageCount: number;
sessionManager?: SessionManager;
withSessionManagerRewriteLock: WithOwnedSessionWriteLock;
}) => {
await finalizeAttemptContextEngineTurn({
contextEngine: activeContextEngine,
promptError: Boolean(state.promptError),
aborted: lifecycleState.aborted,
yieldAborted: state.yieldAborted,
sessionIdUsed,
sessionKey: attempt.sessionKey,
sessionTarget: attempt.sessionTarget,
sessionFile: attempt.sessionFile,
messagesSnapshot: transcript.messagesSnapshot,
prePromptMessageCount: transcript.prePromptMessageCount,
tokenBudget: attempt.contextTokenBudget,
runtimeContext: afterTurnRuntimeContext,
contextEngineHostSupport: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST,
providerId: attempt.provider,
requestedModelId: attempt.requestedModelId,
modelId: attempt.modelId,
fallbackReason: attempt.fallbackReason,
degradedReason: attempt.degradedReason,
runMaintenance: async (contextParams) =>
await runContextEngineMaintenance({
...contextParams,
contextEngine: contextParams.contextEngine as never,
sessionManager: contextParams.sessionManager as never,
withSessionManagerRewriteLock: transcript.withSessionManagerRewriteLock,
config: attempt.config,
agentId: runtime.sessionAgentId,
}),
sessionManager: transcript.sessionManager,
config: attempt.config,
warn: (message) => log.warn(message),
isHeartbeat: isHeartbeatLifecycleRunKind(attempt.bootstrapContextRunKind),
});
};
if (attempt.onContextEngineTurnCandidate) {
const admission = attempt.userTurnTranscriptRecorder?.getAdmissionReceipt();
const terminalEntryId = sessionManager.getLeafId() ?? undefined;
const terminal =
admission && terminalEntryId
? readActiveTranscriptEntryAnchor({
agentId: admission.agentId,
sessionId: admission.sessionId,
sessionKey: admission.sessionKey,
storePath: admission.storePath,
entryId: terminalEntryId,
})
: undefined;
if (admission && terminal) {
attempt.onContextEngineTurnCandidate({
boundary: { admission, terminal },
sessionIdUsed,
sessionKey: attempt.sessionKey,
sessionTarget: attempt.sessionTarget,
sessionFile: attempt.sessionFile,
promptError: Boolean(state.promptError),
aborted: lifecycleState.aborted,
yieldAborted: state.yieldAborted,
tokenBudget: attempt.contextTokenBudget,
runtimeContext: afterTurnRuntimeContext,
contextEngineHostSupport: OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST,
providerId: attempt.provider,
requestedModelId: attempt.requestedModelId,
modelId: attempt.modelId,
fallbackReason: attempt.fallbackReason,
degradedReason: attempt.degradedReason,
config: attempt.config,
agentId: runtime.sessionAgentId,
}),
sessionManager,
config: attempt.config,
warn: (message) => log.warn(message),
isHeartbeat: isHeartbeatLifecycleRunKind(attempt.bootstrapContextRunKind),
});
isHeartbeat: isHeartbeatLifecycleRunKind(attempt.bootstrapContextRunKind),
});
}
} else {
await finalizeTurn({
messagesSnapshot: state.messagesSnapshot,
prePromptMessageCount:
state.contextEngineAfterTurnCheckpoint ?? state.prePromptMessageCount,
sessionManager,
withSessionManagerRewriteLock: input.withOwnedSessionWriteLock,
});
}
}
if (!state.beforeAgentFinalizeRevisionReason) {
@@ -224,6 +224,7 @@ export async function prepareEmbeddedAttemptHistory(input: {
prompt,
});
const messageBudget = Math.max(1, promptBudget - renderedPromptTokens);
const transcriptReadFence = attempt.userTurnTranscriptRecorder?.getAdmissionReceipt();
const assembled = await assembleAttemptContextEngine({
contextEngine: input.activeContextEngine,
sessionId: attempt.sessionId,
@@ -240,6 +241,7 @@ export async function prepareEmbeddedAttemptHistory(input: {
requestedModelId: attempt.requestedModelId,
fallbackReason: attempt.fallbackReason,
degradedReason: attempt.degradedReason,
transcriptReadFence,
...(attempt.prompt !== undefined ? { prompt } : {}),
});
if (!assembled) {
@@ -292,6 +292,73 @@ describe("embedded attempt phase lifecycle state", () => {
});
});
it("records embedded turn facts for the outer fallback owner", async () => {
const afterTurn = vi.fn(async () => {});
const maintain = vi.fn(async () => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}));
const onContextEngineTurnCandidate = vi.fn();
await completeEmbeddedAttemptAfterTurn({
attempt: {
runId: "run-1",
sessionId: "session-1",
sessionKey: "agent:main:main",
sessionFile: "/tmp/session.jsonl",
provider: "test",
modelId: "model",
model: { api: "openai-responses" },
onContextEngineTurnCandidate,
} as never,
activeContextEngine: {
info: { id: "test", name: "Test" },
assemble: vi.fn(),
compact: vi.fn(),
ingest: vi.fn(),
afterTurn,
maintain,
} as never,
activeSession: {} as never,
sessionManager: { appendCustomEntry: vi.fn(), getLeafId: vi.fn(() => "terminal") } as never,
sessionLockController: {} as never,
withOwnedSessionWriteLock: async (operation) => await operation(),
state: {
promptError: null,
yieldAborted: false,
sessionIdUsed: "session-1",
messagesSnapshot: [{ role: "assistant", content: "done" }] as never,
prePromptMessageCount: 0,
contextEngineAfterTurnCheckpoint: null,
compactionOccurredThisAttempt: false,
},
readLifecycleState: () => ({
aborted: false,
timedOut: false,
idleTimedOut: false,
timedOutDuringCompaction: false,
}),
runtime: {
effectiveWorkspace: "/tmp/workspace",
agentDir: "/tmp/agent",
sessionAgentId: "main",
resolveActiveContextEnginePluginId: () => "test",
shouldRecordCompletedBootstrapTurn: false,
cacheTrace: null,
anthropicPayloadLogger: null,
hookAgentId: "main",
diagnosticTrace: { traceId: "trace-1", spanId: "span-1" } as never,
skillWorkshopAvailable: false,
hookRunner: null,
promptStartedAt: Date.now(),
},
});
expect(afterTurn).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
expect(onContextEngineTurnCandidate).not.toHaveBeenCalled();
});
it("emits an abort-classified agent_end event when a teardown error races the abort", async () => {
const abortError = Object.assign(new Error("This operation was aborted"), {
name: "AbortError",
@@ -523,6 +523,7 @@ type AfterTurnRuntimeContextAttempt = Pick<
| "authProfileId"
| "authProfileIdSource"
| "runtimePlan"
| "userTurnTranscriptRecorder"
> & {
sessionId?: EmbeddedRunAttemptParams["sessionId"];
};
@@ -41,6 +41,8 @@ import type {
ToolResultFormat,
} from "../../embedded-agent-subscribe.shared-types.js";
import type { FastModeAutoProgressState } from "../../fast-mode.js";
import type { ContextEngineLogicalTurnLease } from "../../harness/context-engine-logical-turn.js";
import type { ContextEngineTurnAttemptFacts } from "../../harness/context-engine-turn-attempt.js";
import type { ExpectedAgentHarnessRuntimeArtifact } from "../../harness/runtime-artifact.types.js";
import type { AgentInternalEvent } from "../../internal-events.js";
import type { AgentRunSessionTarget } from "../../run-session-target.js";
@@ -388,6 +390,10 @@ export type RunEmbeddedAgentParams = {
suppressTranscriptOnlyAssistantPersistence?: boolean;
suppressAssistantErrorPersistence?: boolean;
userTurnTranscriptRecorder?: UserTurnTranscriptRecorder;
/** Context engine resolved once by the outer logical-turn owner. */
contextEngineLogicalTurnLease?: ContextEngineLogicalTurnLease;
/** Emits immutable attempt facts for selection by the outer logical-turn owner. */
onContextEngineTurnCandidate?: (facts: ContextEngineTurnAttemptFacts) => void;
/** Keep an internal continuation prompt from being replaced by the original prepared turn. */
skipPreparedUserTurnMessage?: boolean;
onUserMessagePersisted?: (message: Extract<AgentMessage, { role: "user" }>) => void;
@@ -175,6 +175,8 @@ export type EmbeddedRunAttemptResult = {
assistantTranscriptOwned?: boolean;
/** Exact idempotency key for the runtime-owned final-assistant transcript row. */
assistantTranscriptIdempotencyKey?: string;
/** Host-private terminal identity used to close the accepted transcript turn. */
contextEngineTerminalAnchor?: import("../../../config/sessions/transcript-entry-anchor.js").TranscriptEntryAnchor;
preflightRecovery?:
| {
route: Exclude<PreemptiveCompactionRoute, "fits">;
+39
View File
@@ -169,6 +169,45 @@ describe("fallback-skip-cache", () => {
).toBe(false);
});
it("isolates entries across explicit and automatic auth scopes", () => {
markFallbackCandidateSkipped({
sessionId: "s1",
provider: "anthropic",
model: "claude-opus-4-7",
authScope: "anthropic:profile-a",
reason: "auth",
now: 1_000,
ttlMs: 60_000,
});
expect(
isFallbackCandidateSkipped({
sessionId: "s1",
provider: "anthropic",
model: "claude-opus-4-7",
authScope: "anthropic:profile-a",
now: 30_000,
}),
).toBe(true);
expect(
isFallbackCandidateSkipped({
sessionId: "s1",
provider: "anthropic",
model: "claude-opus-4-7",
authScope: "anthropic:profile-b",
now: 30_000,
}),
).toBe(false);
expect(
isFallbackCandidateSkipped({
sessionId: "s1",
provider: "anthropic",
model: "claude-opus-4-7",
now: 30_000,
}),
).toBe(false);
});
it("re-marking the same triple refreshes the TTL", () => {
markFallbackCandidateSkipped({
sessionId: "s1",
+12 -9
View File
@@ -5,10 +5,10 @@
* credential error (`auth` / `auth_permanent`), the chain can avoid retrying
* the same candidate on every subsequent turn until the user fixes their auth.
*
* This module records skip markers per `(sessionId, provider, model)` with a
* short TTL. The cache is intentionally in-memory only: a process restart
* clears it so a freshly-restarted gateway always tries every candidate at
* least once before deciding to skip again.
* This module records skip markers per `(sessionId, provider, model, authScope)`
* with a short TTL. The cache is intentionally in-memory only: a process
* restart clears it so a freshly-restarted gateway always tries every
* candidate at least once before deciding to skip again.
*
* The cache is global, not per-config, so any caller running fallbacks for the
* same `sessionId` shares the same skip set.
@@ -98,8 +98,8 @@ function sessionBucket(sessionId: string, create: boolean): Map<string, SkipEntr
return bucket;
}
function candidateKey(provider: string, model: string): string {
return modelKey(provider, model);
function candidateKey(provider: string, model: string, authScope?: string): string {
return JSON.stringify([modelKey(provider, model), authScope?.trim() || null]);
}
function pruneExpired(bucket: Map<string, SkipEntry>, now: number): void {
@@ -140,6 +140,7 @@ export function markFallbackCandidateSkipped(params: {
sessionId: string | undefined;
provider: string;
model: string;
authScope?: string;
reason: string;
now?: number;
ttlMs?: number;
@@ -157,7 +158,7 @@ export function markFallbackCandidateSkipped(params: {
if (!bucket) {
return;
}
bucket.set(candidateKey(params.provider, params.model), {
bucket.set(candidateKey(params.provider, params.model, params.authScope), {
expiresAtMs: now + ttlMs,
reason: params.reason,
});
@@ -172,6 +173,7 @@ export function isFallbackCandidateSkipped(params: {
sessionId: string | undefined;
provider: string;
model: string;
authScope?: string;
now?: number;
}): boolean {
if (!params.sessionId || !params.provider || !params.model) {
@@ -188,7 +190,7 @@ export function isFallbackCandidateSkipped(params: {
getBuckets().delete(params.sessionId);
return false;
}
const entry = bucket.get(candidateKey(params.provider, params.model));
const entry = bucket.get(candidateKey(params.provider, params.model, params.authScope));
return Boolean(entry && entry.expiresAtMs > now);
}
@@ -201,6 +203,7 @@ export function getFallbackCandidateSkipReason(params: {
sessionId: string | undefined;
provider: string;
model: string;
authScope?: string;
now?: number;
}): string | undefined {
if (!params.sessionId || !params.provider || !params.model) {
@@ -211,7 +214,7 @@ export function getFallbackCandidateSkipReason(params: {
return undefined;
}
const now = params.now ?? Date.now();
const entry = bucket.get(candidateKey(params.provider, params.model));
const entry = bucket.get(candidateKey(params.provider, params.model, params.authScope));
if (!entry || entry.expiresAtMs <= now) {
return undefined;
}
@@ -590,4 +590,35 @@ describe("harness context engine lifecycle", () => {
expect(ingestParams.isHeartbeat).toBe(true);
}
});
it.each([
{ promptError: true, aborted: false, yieldAborted: false },
{ promptError: false, aborted: true, yieldAborted: false },
{ promptError: false, aborted: false, yieldAborted: true },
])("does not advance context ingestion for unsuccessful turns: %o", async (terminal) => {
const afterTurn = vi.fn(async () => {});
const ingest = vi.fn(async () => ({ ingested: true }));
const ingestBatch = vi.fn(async () => ({ ingestedCount: 0 }));
const maintain = vi.fn(async () => ({
changed: false,
bytesFreed: 0,
rewrittenEntries: 0,
}));
await finalizeHarnessContextEngineTurn({
contextEngine: createContextEngine({ afterTurn, ingest, ingestBatch, maintain }),
...terminal,
sessionIdUsed: sessionParams.sessionIdUsed,
sessionKey: sessionParams.sessionKey,
sessionFile: sessionParams.sessionFile,
messagesSnapshot: [textMessage("user", "failed", 1)],
prePromptMessageCount: 0,
warn: () => {},
});
expect(afterTurn).not.toHaveBeenCalled();
expect(ingest).not.toHaveBeenCalled();
expect(ingestBatch).not.toHaveBeenCalled();
expect(maintain).not.toHaveBeenCalled();
});
});
+66 -30
View File
@@ -1,3 +1,4 @@
import { runWithSessionTranscriptReadFence } from "../../config/sessions/session-transcript-read-fence.js";
/**
* Manages context-engine lifecycle hooks for native agent harnesses.
*/
@@ -16,6 +17,7 @@ import type {
} from "../../context-engine/types.js";
import { runWithPreparedMemoryPromptSection } from "../../plugins/memory-state.js";
import { resolveAgentIdFromSessionKey } from "../../routing/session-key.js";
import type { UserTurnTranscriptAdmissionReceipt } from "../../sessions/user-turn-transcript.types.js";
import { runContextEngineMaintenance } from "../embedded-agent-runner/context-engine-maintenance.js";
import {
buildAfterTurnRuntimeContext,
@@ -27,6 +29,23 @@ import type { SessionWriteLockAcquireTimeoutConfig } from "../session-write-lock
type HarnessContextEngine = ContextEngine;
function preparePreTurnRuntimeContext(
runtimeContext: ContextEngineRuntimeContext | undefined,
): ContextEngineRuntimeContext | undefined {
if (!runtimeContext?.rewriteTranscriptEntries) {
return runtimeContext;
}
const { rewriteTranscriptEntries: _rewriteTranscriptEntries, ...fenced } = runtimeContext;
return fenced;
}
function runWithHarnessContextEngineTranscriptFence<T>(
transcriptReadFence: UserTurnTranscriptAdmissionReceipt | undefined,
run: () => T,
): T {
return runWithSessionTranscriptReadFence(transcriptReadFence, run);
}
type HarnessRuntimeSettingsParams = {
runtimeSettings?: ContextEngineRuntimeSettings;
contextEngineHostSupport?: ContextEngineHostSupport;
@@ -85,6 +104,7 @@ export async function bootstrapHarnessContextEngine(params: {
sessionFile: string;
sessionManager?: unknown;
runtimeContext?: ContextEngineRuntimeContext;
transcriptReadFence?: UserTurnTranscriptAdmissionReceipt;
runtimeSettings?: ContextEngineRuntimeSettings;
contextEngineHostSupport?: ContextEngineHostSupport;
harnessId?: string | null;
@@ -107,27 +127,30 @@ export async function bootstrapHarnessContextEngine(params: {
}
try {
const runtimeSettings = buildHarnessContextEngineRuntimeSettings(params);
if (typeof params.contextEngine?.bootstrap === "function") {
await params.contextEngine.bootstrap({
const runtimeContext = preparePreTurnRuntimeContext(params.runtimeContext);
await runWithHarnessContextEngineTranscriptFence(params.transcriptReadFence, async () => {
if (typeof params.contextEngine?.bootstrap === "function") {
await params.contextEngine.bootstrap({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionTarget: params.sessionTarget,
sessionFile: params.sessionFile,
runtimeSettings,
runtimeContext,
});
}
await (params.runMaintenance ?? runHarnessContextEngineMaintenance)({
contextEngine: params.contextEngine,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionTarget: params.sessionTarget,
sessionFile: params.sessionFile,
reason: "bootstrap",
sessionManager: params.sessionManager,
runtimeContext,
runtimeSettings,
runtimeContext: params.runtimeContext,
config: params.config,
});
}
await (params.runMaintenance ?? runHarnessContextEngineMaintenance)({
contextEngine: params.contextEngine,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
sessionTarget: params.sessionTarget,
sessionFile: params.sessionFile,
reason: "bootstrap",
sessionManager: params.sessionManager,
runtimeContext: params.runtimeContext,
runtimeSettings,
config: params.config,
});
} catch (bootstrapErr) {
params.warn(`context engine bootstrap failed: ${String(bootstrapErr)}`);
@@ -158,6 +181,8 @@ export async function assembleHarnessContextEngine(params: {
maxOutputTokens?: number | null;
fallbackReason?: string | null;
degradedReason?: string | null;
runtimeContext?: ContextEngineRuntimeContext;
transcriptReadFence?: UserTurnTranscriptAdmissionReceipt;
}) {
if (!params.contextEngine) {
return undefined;
@@ -165,6 +190,7 @@ export async function assembleHarnessContextEngine(params: {
const contextEngine = params.contextEngine;
const messages = stripRuntimeContextCustomMessages(params.messages);
const runtimeSettings = buildHarnessContextEngineRuntimeSettings(params);
const runtimeContext = preparePreTurnRuntimeContext(params.runtimeContext);
const assemble = () =>
contextEngine.assemble({
sessionId: params.sessionId,
@@ -175,21 +201,25 @@ export async function assembleHarnessContextEngine(params: {
...(params.citationsMode ? { citationsMode: params.citationsMode } : {}),
model: params.modelId,
runtimeSettings,
runtimeContext,
...(params.prompt !== undefined ? { prompt: params.prompt } : {}),
});
const result =
contextEngine.info.id === "legacy"
? await assemble()
: await runWithPreparedMemoryPromptSection(
{
availableTools: new Set(params.availableTools),
citationsMode: params.citationsMode,
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
agentSessionKey: params.sessionKey,
sandboxed: params.sandboxed,
},
assemble,
);
const result = await runWithHarnessContextEngineTranscriptFence(
params.transcriptReadFence,
async () =>
contextEngine.info.id === "legacy"
? await assemble()
: await runWithPreparedMemoryPromptSection(
{
availableTools: new Set(params.availableTools),
citationsMode: params.citationsMode,
agentId: resolveAgentIdFromSessionKey(params.sessionKey),
agentSessionKey: params.sessionKey,
sandboxed: params.sandboxed,
},
assemble,
),
);
return ensureAssembleResultShape(result, contextEngine.info.id);
}
@@ -264,12 +294,16 @@ export async function finalizeHarnessContextEngineTurn(params: {
if (!params.contextEngine) {
return { postTurnFinalizationSucceeded: true };
}
if (params.promptError || params.aborted || params.yieldAborted) {
return { postTurnFinalizationSucceeded: true };
}
const conversationSnapshot = buildContextEngineConversationSnapshot({
messagesSnapshot: params.messagesSnapshot,
prePromptMessageCount: params.prePromptMessageCount,
});
const runtimeSettings = buildHarnessContextEngineRuntimeSettings(params);
const runtimeContext = params.runtimeContext;
let postTurnFinalizationSucceeded = true;
if (typeof params.contextEngine.afterTurn === "function") {
@@ -283,7 +317,7 @@ export async function finalizeHarnessContextEngineTurn(params: {
prePromptMessageCount: conversationSnapshot.prePromptMessageCount,
tokenBudget: params.tokenBudget,
runtimeSettings,
runtimeContext: params.runtimeContext,
runtimeContext,
isHeartbeat: params.isHeartbeat,
});
} catch (afterTurnErr) {
@@ -339,7 +373,7 @@ export async function finalizeHarnessContextEngineTurn(params: {
sessionFile: params.sessionFile,
reason: "turn",
sessionManager: params.sessionManager,
runtimeContext: params.runtimeContext,
runtimeContext,
runtimeSettings,
config: params.config,
});
@@ -407,6 +441,7 @@ export async function runHarnessContextEngineMaintenance(params: {
degradedReason?: string | null;
executionMode?: "foreground" | "background";
onDeferredMaintenance?: (promise: Promise<void>) => void;
withSessionManagerRewriteLock?: <T>(operation: () => Promise<T> | T) => Promise<T>;
config?: SessionWriteLockAcquireTimeoutConfig;
}) {
const runtimeSettings = buildHarnessContextEngineRuntimeSettings(params);
@@ -420,6 +455,7 @@ export async function runHarnessContextEngineMaintenance(params: {
sessionManager: params.sessionManager as Parameters<
typeof runContextEngineMaintenance
>[0]["sessionManager"],
withSessionManagerRewriteLock: params.withSessionManagerRewriteLock,
runtimeContext: params.runtimeContext,
runtimeSettings,
executionMode: params.executionMode,
@@ -0,0 +1,217 @@
import { sanitizeForLog } from "../../../packages/terminal-core/src/ansi.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import {
evaluateContextEngineHostSupport,
type ContextEngineHostSupport,
} from "../../context-engine/host-compat.js";
import { ensureContextEnginesInitialized } from "../../context-engine/init.js";
import { resolveLogicalTurnContextEngines } from "../../context-engine/registry.js";
import type { ContextEngine, ContextEngineOperation } from "../../context-engine/types.js";
import type { UserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.types.js";
type LogicalTurnSelectionState = "unselected" | "selected" | "started" | "disposed";
type EffectiveContextEngineRef = Readonly<{
engine: ContextEngine;
registeredId: string;
ownerPluginId?: string;
mode: "configured" | "legacy-degraded";
reason?: string;
}>;
export type ContextEngineLogicalTurnLease = {
/** Compatibility getter for internal callers while the single context object is threaded. */
readonly engine: ContextEngine;
readonly effectiveEngine: ContextEngine;
readonly effectiveEngineId: string;
readonly effectiveEnginePluginId?: string;
readonly degraded: boolean;
readonly degradedReason?: string;
selectForHost: (params: {
host: ContextEngineHostSupport;
operation: ContextEngineOperation;
requiresDurableCommit: boolean;
hasAdmissionFence: boolean;
}) => EffectiveContextEngineRef;
degradeBeforeStart: (reason: string) => EffectiveContextEngineRef;
begin: () => EffectiveContextEngineRef;
deferDisposalUntil: (promise: Promise<unknown>) => void;
dispose: () => Promise<void>;
};
export function selectContextEngineForTranscriptHost(params: {
lease: ContextEngineLogicalTurnLease;
host: ContextEngineHostSupport;
operation: ContextEngineOperation;
recorder: Pick<UserTurnTranscriptRecorder, "getAdmissionReceipt"> | undefined;
}): EffectiveContextEngineRef {
const admission = params.recorder?.getAdmissionReceipt();
if (params.recorder && !admission) {
return params.lease.degradeBeforeStart(
"current-turn transcript admission receipt is unavailable",
);
}
return params.lease.selectForHost({
host: params.host,
operation: params.operation,
requiresDurableCommit: params.recorder !== undefined,
hasAdmissionFence: admission !== undefined,
});
}
export async function createContextEngineLogicalTurnLease(params: {
config?: OpenClawConfig;
agentDir?: string;
workspaceDir?: string;
warn?: (message: string) => void;
}): Promise<ContextEngineLogicalTurnLease> {
ensureContextEnginesInitialized();
const resolution = await resolveLogicalTurnContextEngines(params.config, {
agentDir: params.agentDir,
workspaceDir: params.workspaceDir,
});
let state: LogicalTurnSelectionState = "unselected";
let effective = resolution.configured;
let degradedReason = resolution.configuredFailure;
let warned = false;
const disposalHolds = new Set<Promise<unknown>>();
const asEffective = (): EffectiveContextEngineRef =>
Object.freeze({
...effective,
mode: degradedReason ? "legacy-degraded" : "configured",
...(degradedReason ? { reason: degradedReason } : {}),
});
const warnOnce = (reason: string) => {
if (warned) {
return;
}
warned = true;
(params.warn ?? console.warn)(
`[context-engine] Context engine "${sanitizeForLog(resolution.configuredId)}" degraded to "${sanitizeForLog(resolution.fallback.registeredId)}" for this logical turn: ${sanitizeForLog(reason)}`,
);
};
const degradeBeforeStart = (reason: string): EffectiveContextEngineRef => {
if (state === "started" || state === "disposed") {
throw new Error("context-engine logical turn selection is already pinned");
}
degradedReason ??= reason;
effective = resolution.fallback;
state = "selected";
warnOnce(degradedReason);
return asEffective();
};
const resolveSelectionIssue = (selection: {
host: ContextEngineHostSupport;
operation: ContextEngineOperation;
requiresDurableCommit: boolean;
hasAdmissionFence: boolean;
}): string | undefined => {
const support = evaluateContextEngineHostSupport({
contextEngineInfo: effective.engine.info,
operation: selection.operation,
host: selection.host,
});
if (!support.ok) {
return `host "${selection.host.id}" is missing ${support.missingCapabilities.join(", ")}`;
}
if (
selection.hasAdmissionFence &&
effective.engine.info.transcriptSemantics?.currentTurnFence !== "before-current-turn-entry-v1"
) {
return "current-turn transcript fencing is not declared";
}
if (
selection.requiresDurableCommit &&
(effective.engine.info.transcriptSemantics?.turnAdvancementIdempotency !==
"atomic-idempotent-v1" ||
typeof effective.engine.commitTurn !== "function")
) {
return "atomic idempotent turn advancement is not declared";
}
return undefined;
};
if (resolution.configuredFailure) {
degradeBeforeStart(resolution.configuredFailure);
}
const lease: ContextEngineLogicalTurnLease = {
get engine() {
return effective.engine;
},
get effectiveEngine() {
return effective.engine;
},
get effectiveEngineId() {
return effective.registeredId;
},
get effectiveEnginePluginId() {
return effective.ownerPluginId;
},
get degraded() {
return degradedReason !== undefined;
},
get degradedReason() {
return degradedReason;
},
selectForHost(selection) {
if (state === "disposed") {
throw new Error("context-engine logical turn lease is already disposed");
}
if (degradedReason) {
return asEffective();
}
const issue = resolveSelectionIssue(selection);
if (issue) {
if (state === "started") {
throw new Error(
`context-engine logical turn cannot change to incompatible ${selection.host.label}: ${issue}`,
);
}
return degradeBeforeStart(issue);
}
if (state === "unselected") {
state = "selected";
}
return asEffective();
},
degradeBeforeStart,
begin() {
if (state === "disposed") {
throw new Error("context-engine logical turn lease is already disposed");
}
state = "started";
return asEffective();
},
deferDisposalUntil(promise) {
if (state === "disposed") {
throw new Error("context-engine logical turn lease is already disposed");
}
disposalHolds.add(promise);
void promise.finally(() => disposalHolds.delete(promise)).catch(() => {});
},
async dispose() {
if (state === "disposed") {
return;
}
state = "disposed";
const engines = new Set<ContextEngine>([
resolution.configured.engine,
resolution.fallback.engine,
]);
const disposeEngines = async () => {
await Promise.allSettled([...engines].map(async (engine) => await engine.dispose?.()));
};
if (disposalHolds.size > 0) {
void Promise.allSettled(disposalHolds).then(disposeEngines);
return;
}
await disposeEngines();
},
};
return lease;
}
@@ -13,6 +13,7 @@ import {
upsertSessionEntry,
} from "../../config/sessions/session-accessor.js";
import type { ContextEngine, ContextEngineSessionTarget } from "../../context-engine/types.js";
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
import {
bootstrapHarnessContextEngine,
finalizeHarnessContextEngineTurn,
@@ -51,6 +52,7 @@ describe("context engine transcript cursor contract", () => {
agentId: "main",
sessionId: "context-engine-cursor",
sessionKey: "agent:main:context-engine-cursor",
sessionEntry: { sessionId: "context-engine-cursor", updatedAt: 10 },
storePath,
};
const projectedMessages: AgentMessage[] = [];
@@ -87,7 +89,11 @@ describe("context engine transcript cursor contract", () => {
}
};
const engine: ContextEngine = {
info: { id: "cursor-proof", name: "Cursor proof" },
info: {
id: "cursor-proof",
name: "Cursor proof",
transcriptSemantics: { currentTurnFence: "before-current-turn-entry-v1" },
},
bootstrap: async (params) => {
await consumeVisibleTranscript(params);
return { bootstrapped: true, importedMessages: projectedMessages.length };
@@ -112,6 +118,15 @@ describe("context engine transcript cursor contract", () => {
parentId: first?.messageId,
now: 2_000,
});
const admitted = await createUserTurnTranscriptRecorder({
message: { role: "user", content: "third", timestamp: 3_000 },
target,
updateMode: "none",
}).persistApproved();
if (!admitted) {
throw new Error("expected admitted user message with transcript admission");
}
const admission = admitted.admission;
await bootstrapHarnessContextEngine({
hadSessionFile: true,
@@ -120,14 +135,16 @@ describe("context engine transcript cursor contract", () => {
sessionKey: target.sessionKey,
sessionTarget: target,
sessionFile: "sqlite://context-engine-cursor",
transcriptReadFence: admission,
runMaintenance: skipMaintenance,
warn: () => {},
});
expect(projectedMessages.map(readMessageContent)).toEqual(["first", "second"]);
await appendTranscriptMessage(target, {
message: { role: "user", content: "third" },
now: 3_000,
message: { role: "assistant", content: "fourth" },
parentId: admitted.messageId,
now: 4_000,
});
await finalizeHarnessContextEngineTurn({
contextEngine: engine,
@@ -143,14 +160,44 @@ describe("context engine transcript cursor contract", () => {
runMaintenance: skipMaintenance,
warn: () => {},
});
expect(projectedMessages.map(readMessageContent)).toEqual(["first", "second", "third"]);
expect(projectedMessages.map(readMessageContent)).toEqual([
"first",
"second",
"third",
"fourth",
]);
await appendTranscriptMessage(target, {
message: { role: "user", content: "failed turn" },
now: 5_000,
});
await finalizeHarnessContextEngineTurn({
contextEngine: engine,
promptError: true,
aborted: false,
yieldAborted: false,
sessionIdUsed: target.sessionId,
sessionKey: target.sessionKey,
sessionTarget: target,
sessionFile: "sqlite://context-engine-cursor",
messagesSnapshot: [],
prePromptMessageCount: 0,
runMaintenance: skipMaintenance,
warn: () => {},
});
expect(projectedMessages.map(readMessageContent)).toEqual([
"first",
"second",
"third",
"fourth",
]);
await replaceTranscriptEvents(target, [
{
type: "message",
id: "replacement",
parentId: null,
timestamp: "1970-01-01T00:00:04.000Z",
timestamp: "1970-01-01T00:00:06.000Z",
message: { role: "user", content: "replacement" },
},
]);
@@ -0,0 +1,259 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
appendTranscriptMessage,
readActiveTranscriptEntryAnchor,
upsertSessionEntry,
} from "../../config/sessions/session-accessor.js";
import type { ContextEngine } from "../../context-engine/types.js";
import {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,
} from "../../state/openclaw-agent-db.js";
import type { ContextEngineLogicalTurnLease } from "./context-engine-logical-turn.js";
import {
drainPendingContextEngineTurnsBeforeRun,
finalizeAcceptedContextEngineTurn,
} from "./context-engine-turn-attempt.js";
import { enqueueContextEngineTurnIntent } from "./context-engine-turn-outbox.js";
afterEach(() => {
closeOpenClawAgentDatabasesForTest();
});
describe("accepted context-engine turn finalization", () => {
it("advances only the admitted durable range and rejects stale admission facts", async () => {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-turn-attempt-"));
const target = {
agentId: "main",
sessionId: "accepted-turn",
sessionKey: "agent:main:accepted-turn",
storePath: path.join(tempDir, "sessions.json"),
};
await upsertSessionEntry(target, { sessionId: target.sessionId, updatedAt: 1 });
const prior = await appendTranscriptMessage(target, {
message: { role: "assistant", content: "prior" },
now: 1_000,
});
const admitted = await appendTranscriptMessage(target, {
message: { role: "user", content: "current" },
parentId: prior?.messageId,
now: 2_000,
});
const terminal = await appendTranscriptMessage(target, {
message: { role: "assistant", content: "answer" },
parentId: admitted?.messageId,
now: 3_000,
});
if (!admitted?.anchor || !terminal?.anchor) {
throw new Error("expected admitted turn transcript");
}
const commitTurn = vi.fn(async () => ({ status: "committed" as const }));
const engine: ContextEngine = {
info: {
id: "test",
name: "Test",
transcriptSemantics: {
currentTurnFence: "before-current-turn-entry-v1",
turnAdvancementIdempotency: "atomic-idempotent-v1",
},
},
ingest: async () => ({ ingested: true }),
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
compact: async () => ({ ok: true, compacted: false }),
commitTurn,
};
const lease = {
engine,
effectiveEngine: engine,
effectiveEngineId: "test",
effectiveEnginePluginId: undefined,
degraded: false,
degradedReason: undefined,
selectForHost: vi.fn(),
degradeBeforeStart: vi.fn(),
begin: vi.fn(),
deferDisposalUntil: () => undefined,
dispose: async () => undefined,
} satisfies ContextEngineLogicalTurnLease;
const admission = {
...admitted.anchor,
logicalTurnId: "logical-turn-1",
role: "user" as const,
};
const database = openOpenClawAgentDatabase({
agentId: target.agentId,
path: admission.storePath,
});
enqueueContextEngineTurnIntent({
admission,
database,
engineId: "test",
isHeartbeat: false,
});
const baseFacts = {
boundary: { admission, terminal: terminal.anchor },
sessionIdUsed: target.sessionId,
sessionKey: target.sessionKey,
sessionTarget: target,
sessionFile: "sqlite://accepted-turn",
promptError: false,
aborted: false,
yieldAborted: false,
};
await finalizeAcceptedContextEngineTurn({ facts: baseFacts, lease });
expect(commitTurn).toHaveBeenCalledOnce();
expect(commitTurn).toHaveBeenCalledWith(
expect.objectContaining({
messages: expect.arrayContaining([
expect.objectContaining({ content: "prior" }),
expect.objectContaining({ content: "current" }),
expect.objectContaining({ content: "answer" }),
]),
prePromptMessageCount: 1,
}),
);
const warn = vi.fn();
await finalizeAcceptedContextEngineTurn({
facts: {
...baseFacts,
boundary: {
...baseFacts.boundary,
admission: { ...admission, rawSeq: admission.rawSeq + 1 },
},
},
lease,
warn,
});
expect(commitTurn).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(
"[context-engine] skipped accepted turn advancement: accepted context-engine transcript range is stale",
);
expect(
JSON.parse(
(
database.db
.prepare(
"SELECT payload_json FROM context_engine_turn_outbox WHERE advancement_key = ?",
)
.get(admission.logicalTurnId) as { payload_json: string }
).payload_json,
),
).toMatchObject({ state: "blocked", failure: "stale" });
await drainPendingContextEngineTurnsBeforeRun({
admission,
lease,
warn,
});
expect(lease.degradeBeforeStart).toHaveBeenCalledWith(
"pending durable turn advancement could not be completed before the next turn",
);
const sibling = await appendTranscriptMessage(target, {
message: { role: "assistant", content: "sibling" },
parentId: prior?.messageId,
now: 4_000,
});
if (!sibling) {
throw new Error("expected sibling transcript");
}
const siblingIdentity = database.db
.prepare("SELECT seq FROM transcript_event_identities WHERE session_id = ? AND event_id = ?")
.get(target.sessionId, sibling.messageId) as { seq?: number } | undefined;
if (siblingIdentity?.seq === undefined) {
throw new Error("expected sibling transcript identity");
}
// Model a stale/concurrent projection that assigns a later active position
// to a sibling. Position order alone must not make it an accepted descendant.
database.db
.prepare(
"INSERT INTO session_transcript_active_events (session_id, active_position, event_seq, message_position) VALUES (?, ?, ?, ?)",
)
.run(
target.sessionId,
terminal.anchor.activeMessagePosition + 1,
siblingIdentity.seq,
terminal.anchor.activeMessagePosition + 1,
);
database.db
.prepare(
"UPDATE session_transcript_index_state SET indexed_seq = ?, needs_rebuild = 0 WHERE session_id = ?",
)
.run(siblingIdentity.seq, target.sessionId);
const siblingAnchor = readActiveTranscriptEntryAnchor({
...target,
entryId: sibling.messageId,
});
if (!siblingAnchor) {
throw new Error("expected projected sibling transcript anchor");
}
const siblingAdmission = {
...admission,
logicalTurnId: "logical-turn-2",
};
enqueueContextEngineTurnIntent({
admission: siblingAdmission,
database,
engineId: "test",
isHeartbeat: false,
});
warn.mockClear();
await finalizeAcceptedContextEngineTurn({
facts: {
...baseFacts,
boundary: { admission: siblingAdmission, terminal: siblingAnchor },
},
lease,
warn,
});
expect(commitTurn).toHaveBeenCalledOnce();
expect(warn).toHaveBeenCalledWith(
"[context-engine] skipped accepted turn advancement: accepted context-engine transcript range is non-descendant",
);
expect(
JSON.parse(
(
database.db
.prepare(
"SELECT payload_json FROM context_engine_turn_outbox WHERE advancement_key = ?",
)
.get(siblingAdmission.logicalTurnId) as { payload_json: string }
).payload_json,
),
).toMatchObject({ state: "blocked", failure: "non-descendant" });
const abortedAdmission = {
...admission,
logicalTurnId: "logical-turn-3",
};
enqueueContextEngineTurnIntent({
admission: abortedAdmission,
database,
engineId: "test",
isHeartbeat: false,
});
await finalizeAcceptedContextEngineTurn({
facts: {
...baseFacts,
aborted: true,
boundary: { ...baseFacts.boundary, admission: abortedAdmission },
},
lease,
warn,
});
expect(
database.db
.prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?")
.get(abortedAdmission.logicalTurnId),
).toBeUndefined();
});
});
@@ -0,0 +1,230 @@
import {
readClosedTranscriptTurn,
type TranscriptTurnBoundary,
} from "../../config/sessions/session-accessor.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { ContextEngineHostSupport } from "../../context-engine/host-compat.js";
import type {
ContextEngineRuntimeContext,
ContextEngineRuntimeSettings,
ContextEngineSessionTarget,
} from "../../context-engine/types.js";
import { openOpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
import type { ContextEngineLogicalTurnLease } from "./context-engine-logical-turn.js";
import {
acceptContextEngineTurnIntent,
blockContextEngineTurnIntent,
discardContextEngineTurnIntent,
drainContextEngineTurnOutbox,
enqueueContextEngineTurnCommit,
enqueueContextEngineTurnIntent,
isRetryableContextEngineTurnReadFailure,
recoverContextEngineTurnOutbox,
} from "./context-engine-turn-outbox.js";
const ACCEPTED_TURN_MAX_EVENTS = 20_000;
const ACCEPTED_TURN_MAX_BYTES = 8 * 1024 * 1024;
export type ContextEngineTurnAttemptFacts = {
boundary: TranscriptTurnBoundary;
sessionIdUsed: string;
sessionKey?: string;
sessionTarget?: ContextEngineSessionTarget;
sessionFile: string;
promptError: boolean;
aborted: boolean;
yieldAborted: boolean;
tokenBudget?: number;
runtimeContext?: ContextEngineRuntimeContext;
runtimeSettings?: ContextEngineRuntimeSettings;
contextEngineHostSupport?: ContextEngineHostSupport;
harnessId?: string | null;
runtimeId?: string | null;
providerId?: string | null;
requestedModelId?: string | null;
modelId?: string | null;
maxOutputTokens?: number | null;
fallbackReason?: string | null;
degradedReason?: string | null;
config?: OpenClawConfig;
isHeartbeat?: boolean;
};
export async function drainPendingContextEngineTurnsBeforeRun(params: {
admission: TranscriptTurnBoundary["admission"] | undefined;
isHeartbeat?: boolean;
lease: ContextEngineLogicalTurnLease;
warn?: (message: string) => void;
}): Promise<void> {
if (
!params.admission ||
params.lease.degraded ||
params.lease.engine.info.transcriptSemantics?.turnAdvancementIdempotency !==
"atomic-idempotent-v1" ||
typeof params.lease.engine.commitTurn !== "function"
) {
return;
}
const warn = params.warn ?? console.warn;
try {
const database = openOpenClawAgentDatabase({
agentId: params.admission.agentId,
path: params.admission.storePath,
});
recoverContextEngineTurnOutbox({
currentAdmission: params.admission,
database,
engineId: params.lease.effectiveEngineId,
ownerPluginId: params.lease.effectiveEnginePluginId,
warn,
});
const result = await drainContextEngineTurnOutbox({
database,
engine: params.lease.engine,
engineId: params.lease.effectiveEngineId,
ownerPluginId: params.lease.effectiveEnginePluginId,
sessionId: params.admission.sessionId,
warn,
});
if (result.pending) {
params.lease.degradeBeforeStart(
"pending durable turn advancement could not be completed before the next turn",
);
return;
}
// Persist the admission before provider dispatch. A later run can recover an accepted
// transcript if this process dies before finalization updates the row.
enqueueContextEngineTurnIntent({
admission: params.admission,
database,
engineId: params.lease.effectiveEngineId,
isHeartbeat: params.isHeartbeat === true,
ownerPluginId: params.lease.effectiveEnginePluginId,
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
warn(`[context-engine] failed to retry pending turn advancement: ${message}`);
params.lease.degradeBeforeStart(
"pending durable turn advancement could not be checked before the next turn",
);
}
}
export function discardContextEngineTurnAttemptIntent(params: {
facts: ContextEngineTurnAttemptFacts;
lease: ContextEngineLogicalTurnLease;
warn?: (message: string) => void;
}): void {
const warn = params.warn ?? console.warn;
try {
const admission = params.facts.boundary.admission;
discardContextEngineTurnIntent({
admission,
database: openOpenClawAgentDatabase({
agentId: admission.agentId,
path: admission.storePath,
}),
engineId: params.lease.effectiveEngineId,
ownerPluginId: params.lease.effectiveEnginePluginId,
});
} catch (error) {
warn(
`[context-engine] failed to discard unaccepted turn intent: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
function assertAcceptedTranscriptTarget(facts: ContextEngineTurnAttemptFacts): void {
const { admission, terminal } = facts.boundary;
if (
facts.sessionIdUsed !== admission.sessionId ||
terminal.agentId !== admission.agentId ||
terminal.sessionId !== admission.sessionId ||
terminal.sessionKey !== admission.sessionKey ||
terminal.storePath !== admission.storePath ||
(facts.sessionKey !== undefined && facts.sessionKey !== admission.sessionKey) ||
(facts.sessionTarget?.agentId !== undefined &&
facts.sessionTarget.agentId !== admission.agentId) ||
(facts.sessionTarget?.sessionId !== undefined &&
facts.sessionTarget.sessionId !== admission.sessionId) ||
(facts.sessionTarget?.sessionKey !== undefined &&
facts.sessionTarget.sessionKey !== admission.sessionKey)
) {
throw new Error("accepted context-engine transcript target changed after admission");
}
}
export async function finalizeAcceptedContextEngineTurn(params: {
facts: ContextEngineTurnAttemptFacts;
lease: ContextEngineLogicalTurnLease;
warn?: (message: string) => void;
}): Promise<void> {
const warn = params.warn ?? console.warn;
if (params.facts.promptError || params.facts.aborted || params.facts.yieldAborted) {
discardContextEngineTurnAttemptIntent({ facts: params.facts, lease: params.lease, warn });
return;
}
try {
assertAcceptedTranscriptTarget(params.facts);
if (
params.lease.degraded ||
params.lease.engine.info.transcriptSemantics?.turnAdvancementIdempotency !==
"atomic-idempotent-v1" ||
typeof params.lease.engine.commitTurn !== "function"
) {
throw new Error("accepted context engine does not support durable turn advancement");
}
const admission = params.facts.boundary.admission;
const database = openOpenClawAgentDatabase({
agentId: admission.agentId,
path: admission.storePath,
});
acceptContextEngineTurnIntent({
boundary: params.facts.boundary,
database,
engineId: params.lease.effectiveEngineId,
isHeartbeat: params.facts.isHeartbeat === true,
ownerPluginId: params.lease.effectiveEnginePluginId,
});
const closedTurn = readClosedTranscriptTurn({
boundary: params.facts.boundary,
maxEvents: ACCEPTED_TURN_MAX_EVENTS,
maxBytes: ACCEPTED_TURN_MAX_BYTES,
});
if (closedTurn.kind !== "ok") {
if (!isRetryableContextEngineTurnReadFailure(closedTurn.kind)) {
blockContextEngineTurnIntent({
boundary: params.facts.boundary,
database,
engineId: params.lease.effectiveEngineId,
failure: closedTurn.kind,
isHeartbeat: params.facts.isHeartbeat === true,
ownerPluginId: params.lease.effectiveEnginePluginId,
});
}
throw new Error(`accepted context-engine transcript range is ${closedTurn.kind}`);
}
enqueueContextEngineTurnCommit({
database,
engineId: params.lease.effectiveEngineId,
ownerPluginId: params.lease.effectiveEnginePluginId,
payload: {
boundary: params.facts.boundary,
isHeartbeat: params.facts.isHeartbeat === true,
messages: closedTurn.messages,
prePromptMessageCount: closedTurn.prePromptMessageCount,
},
});
await drainContextEngineTurnOutbox({
database,
engine: params.lease.engine,
engineId: params.lease.effectiveEngineId,
ownerPluginId: params.lease.effectiveEnginePluginId,
warn,
});
} catch (error) {
warn(
`[context-engine] skipped accepted turn advancement: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
@@ -0,0 +1,624 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
appendTranscriptMessage,
upsertSessionEntry,
} from "../../config/sessions/session-accessor.js";
import type {
TranscriptTurnAdmission,
TranscriptTurnBoundary,
} from "../../config/sessions/transcript-entry-anchor.js";
import type { ContextEngine } from "../../context-engine/types.js";
import {
closeOpenClawAgentDatabasesForTest,
openOpenClawAgentDatabase,
} from "../../state/openclaw-agent-db.js";
import type { ContextEngineLogicalTurnLease } from "./context-engine-logical-turn.js";
import { drainPendingContextEngineTurnsBeforeRun } from "./context-engine-turn-attempt.js";
import {
acceptContextEngineTurnIntent,
drainContextEngineTurnOutbox,
enqueueContextEngineTurnCommit,
enqueueContextEngineTurnIntent,
isRetryableContextEngineTurnReadFailure,
recoverContextEngineTurnOutbox,
} from "./context-engine-turn-outbox.js";
const tempDirs: string[] = [];
type ContextEngineTurnOutboxPayload = Parameters<
typeof enqueueContextEngineTurnCommit
>[0]["payload"];
afterEach(() => {
closeOpenClawAgentDatabasesForTest();
for (const tempDir of tempDirs.splice(0)) {
fs.rmSync(tempDir, { recursive: true, force: true });
}
});
function createPayload(params: {
advancementKey: string;
databasePath: string;
sequence: number;
sessionId: string;
}): ContextEngineTurnOutboxPayload {
const anchor = {
agentId: "main",
sessionId: params.sessionId,
sessionKey: `agent:main:${params.sessionId}`,
storePath: params.databasePath,
generation: "generation-1",
entryId: `${params.advancementKey}:user`,
rawSeq: params.sequence,
effectiveParentId: null,
activeMessagePosition: params.sequence,
};
const boundary = {
admission: {
...anchor,
logicalTurnId: params.advancementKey,
role: "user" as const,
},
terminal: {
...anchor,
entryId: `${params.advancementKey}:assistant`,
rawSeq: params.sequence + 1,
effectiveParentId: anchor.entryId,
activeMessagePosition: params.sequence + 1,
},
} satisfies TranscriptTurnBoundary;
return {
boundary,
isHeartbeat: false,
messages: [],
prePromptMessageCount: params.sequence,
};
}
describe("context-engine turn outbox", () => {
it("retries only transcript failures that can make progress", () => {
expect(isRetryableContextEngineTurnReadFailure("projection-unavailable")).toBe(true);
expect(isRetryableContextEngineTurnReadFailure("too-large")).toBe(false);
expect(isRetryableContextEngineTurnReadFailure("stale")).toBe(false);
});
it("retains a queued turn when commitTurn resolves outside its contract", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-contract-"));
tempDirs.push(stateDir);
const database = openOpenClawAgentDatabase({
agentId: "main",
env: { OPENCLAW_STATE_DIR: stateDir },
});
const payload = createPayload({
advancementKey: "session-a:invalid-result",
databasePath: database.path,
sequence: 1,
sessionId: "session-a",
});
enqueueContextEngineTurnCommit({ database, engineId: "test", payload });
let valid = false;
const commitTurn = vi.fn(async () =>
valid ? { status: "committed" as const } : ({ status: "ignored" } as never),
);
const engine = {
info: { id: "test", name: "Test" },
ingest: async () => ({ ingested: true }),
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
compact: async () => ({ ok: true, compacted: false }),
commitTurn,
} satisfies ContextEngine;
const warn = vi.fn();
await drainContextEngineTurnOutbox({ database, engine, engineId: "test", warn });
expect(
database.db
.prepare(
"SELECT attempt_count, last_error FROM context_engine_turn_outbox WHERE advancement_key = ?",
)
.get(payload.boundary.admission.logicalTurnId),
).toEqual({
attempt_count: 1,
last_error: "invalid commitTurn result status: ignored",
});
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("durable turn advancement remains queued"),
);
valid = true;
await drainContextEngineTurnOutbox({ database, engine, engineId: "test", warn });
expect(
database.db
.prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?")
.get(payload.boundary.admission.logicalTurnId),
).toBeUndefined();
});
it("recovers an accepted terminal transcript when finalization crashed", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-recovery-"));
tempDirs.push(stateDir);
const target = {
agentId: "main",
sessionId: "recovered-turn",
sessionKey: "agent:main:recovered-turn",
storePath: path.join(stateDir, "sessions.json"),
};
await upsertSessionEntry(target, { sessionId: target.sessionId, updatedAt: 1 });
const admitted = await appendTranscriptMessage(target, {
message: { role: "user", content: "first" },
now: 1_000,
});
if (!admitted?.anchor) {
throw new Error("expected admitted transcript entry");
}
const admission = {
...admitted.anchor,
logicalTurnId: "recovered-logical-turn",
role: "user" as const,
} satisfies TranscriptTurnAdmission;
const database = openOpenClawAgentDatabase({
agentId: target.agentId,
path: admission.storePath,
});
enqueueContextEngineTurnIntent({
admission,
database,
engineId: "test",
isHeartbeat: true,
});
const terminal = await appendTranscriptMessage(target, {
message: { role: "assistant", content: "first answer" },
parentId: admitted.messageId,
now: 2_000,
});
if (!terminal?.anchor) {
throw new Error("expected terminal transcript entry");
}
acceptContextEngineTurnIntent({
boundary: {
admission,
terminal: terminal.anchor,
},
database,
engineId: "test",
isHeartbeat: true,
});
const current = await appendTranscriptMessage(target, {
message: { role: "user", content: "second" },
parentId: terminal.messageId,
now: 3_000,
});
if (!current?.anchor) {
throw new Error("expected current transcript entry");
}
const currentAdmission = {
...current.anchor,
logicalTurnId: "current-logical-turn",
role: "user" as const,
} satisfies TranscriptTurnAdmission;
const commitTurn = vi.fn(async () => ({ status: "committed" as const }));
const engine = {
info: {
id: "test",
name: "Test",
transcriptSemantics: {
currentTurnFence: "before-current-turn-entry-v1",
turnAdvancementIdempotency: "atomic-idempotent-v1",
},
},
ingest: async () => ({ ingested: true }),
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
compact: async () => ({ ok: true, compacted: false }),
commitTurn,
} satisfies ContextEngine;
const lease = {
engine,
effectiveEngine: engine,
effectiveEngineId: "test",
effectiveEnginePluginId: undefined,
degraded: false,
degradedReason: undefined,
selectForHost: vi.fn(),
degradeBeforeStart: vi.fn(),
begin: vi.fn(),
deferDisposalUntil: vi.fn(),
dispose: vi.fn(async () => undefined),
} satisfies ContextEngineLogicalTurnLease;
await drainPendingContextEngineTurnsBeforeRun({
admission: currentAdmission,
isHeartbeat: false,
lease,
});
expect(commitTurn).toHaveBeenCalledOnce();
expect(commitTurn).toHaveBeenCalledWith(
expect.objectContaining({
advancementKey: admission.logicalTurnId,
isHeartbeat: true,
messages: [
{ role: "user", content: "first" },
{ role: "assistant", content: "first answer" },
],
prePromptMessageCount: 0,
}),
);
const queued = database.db
.prepare("SELECT advancement_key, payload_json FROM context_engine_turn_outbox")
.all() as Array<{ advancement_key: string; payload_json: string }>;
expect(queued).toHaveLength(1);
expect(queued[0]?.advancement_key).toBe(currentAdmission.logicalTurnId);
expect(JSON.parse(queued[0]?.payload_json ?? "{}")).toMatchObject({
state: "admitted",
isHeartbeat: false,
});
expect(lease.degradeBeforeStart).not.toHaveBeenCalled();
});
it("discards admission-only recovery even when the transcript has descendants", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-unaccepted-"));
tempDirs.push(stateDir);
const target = {
agentId: "main",
sessionId: "unaccepted-turn",
sessionKey: "agent:main:unaccepted-turn",
storePath: path.join(stateDir, "sessions.json"),
};
await upsertSessionEntry(target, { sessionId: target.sessionId, updatedAt: 1 });
const admitted = await appendTranscriptMessage(target, {
message: { role: "user", content: "first" },
now: 1_000,
});
if (!admitted?.anchor) {
throw new Error("expected admitted transcript entry");
}
const admission = {
...admitted.anchor,
logicalTurnId: "unaccepted-logical-turn",
role: "user" as const,
} satisfies TranscriptTurnAdmission;
const database = openOpenClawAgentDatabase({
agentId: target.agentId,
path: admission.storePath,
});
enqueueContextEngineTurnIntent({
admission,
database,
engineId: "test",
isHeartbeat: false,
});
const rejected = await appendTranscriptMessage(target, {
message: { role: "assistant", content: "rejected fallback" },
parentId: admitted.messageId,
now: 2_000,
});
const current = await appendTranscriptMessage(target, {
message: { role: "user", content: "second" },
parentId: rejected?.messageId,
now: 3_000,
});
if (!current?.anchor) {
throw new Error("expected current transcript entry");
}
const currentAdmission = {
...current.anchor,
logicalTurnId: "current-logical-turn",
role: "user" as const,
} satisfies TranscriptTurnAdmission;
const commitTurn = vi.fn(async () => ({ status: "committed" as const }));
const engine = {
info: {
id: "test",
name: "Test",
transcriptSemantics: {
currentTurnFence: "before-current-turn-entry-v1",
turnAdvancementIdempotency: "atomic-idempotent-v1",
},
},
ingest: async () => ({ ingested: true }),
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
compact: async () => ({ ok: true, compacted: false }),
commitTurn,
} satisfies ContextEngine;
const lease = {
engine,
effectiveEngine: engine,
effectiveEngineId: "test",
effectiveEnginePluginId: undefined,
degraded: false,
degradedReason: undefined,
selectForHost: vi.fn(),
degradeBeforeStart: vi.fn(),
begin: vi.fn(),
deferDisposalUntil: vi.fn(),
dispose: vi.fn(async () => undefined),
} satisfies ContextEngineLogicalTurnLease;
await drainPendingContextEngineTurnsBeforeRun({
admission: currentAdmission,
isHeartbeat: false,
lease,
});
expect(commitTurn).not.toHaveBeenCalled();
const queued = database.db
.prepare("SELECT advancement_key, payload_json FROM context_engine_turn_outbox")
.all() as Array<{ advancement_key: string; payload_json: string }>;
expect(queued).toHaveLength(1);
expect(queued[0]?.advancement_key).toBe(currentAdmission.logicalTurnId);
expect(JSON.parse(queued[0]?.payload_json ?? "{}")).toMatchObject({
state: "admitted",
isHeartbeat: false,
});
});
it("retains unrecoverable accepted recovery as a blocking marker", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-blocked-"));
tempDirs.push(stateDir);
const database = openOpenClawAgentDatabase({
agentId: "main",
env: { OPENCLAW_STATE_DIR: stateDir },
});
const payload = createPayload({
advancementKey: "session-a:unrecoverable",
databasePath: database.path,
sequence: 1,
sessionId: "session-a",
});
enqueueContextEngineTurnIntent({
admission: payload.boundary.admission,
database,
engineId: "test",
isHeartbeat: false,
});
acceptContextEngineTurnIntent({
boundary: payload.boundary,
database,
engineId: "test",
isHeartbeat: false,
});
const warn = vi.fn();
recoverContextEngineTurnOutbox({
currentAdmission: payload.boundary.admission,
database,
engineId: "test",
warn,
});
const queued = database.db
.prepare("SELECT payload_json FROM context_engine_turn_outbox WHERE advancement_key = ?")
.get(payload.boundary.admission.logicalTurnId) as { payload_json: string };
expect(JSON.parse(queued.payload_json)).toMatchObject({
state: "blocked",
failure: "session-rebound",
});
expect(warn).toHaveBeenCalledWith(
expect.stringContaining("blocked unrecoverable turn advancement"),
);
const engine = {
info: {
id: "test",
name: "Test",
transcriptSemantics: {
currentTurnFence: "before-current-turn-entry-v1",
turnAdvancementIdempotency: "atomic-idempotent-v1",
},
},
ingest: async () => ({ ingested: true }),
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
compact: async () => ({ ok: true, compacted: false }),
commitTurn: vi.fn(async () => ({ status: "committed" as const })),
} satisfies ContextEngine;
const degradeBeforeStart = vi.fn();
const lease = {
engine,
effectiveEngine: engine,
effectiveEngineId: "test",
effectiveEnginePluginId: undefined,
degraded: false,
degradedReason: undefined,
selectForHost: vi.fn(),
degradeBeforeStart,
begin: vi.fn(),
deferDisposalUntil: vi.fn(),
dispose: vi.fn(async () => undefined),
} satisfies ContextEngineLogicalTurnLease;
await drainPendingContextEngineTurnsBeforeRun({
admission: payload.boundary.admission,
lease,
warn,
});
expect(engine.commitTurn).not.toHaveBeenCalled();
expect(degradeBeforeStart).toHaveBeenCalledWith(
"pending durable turn advancement could not be completed before the next turn",
);
expect(
database.db
.prepare("SELECT 1 FROM context_engine_turn_outbox WHERE advancement_key = ?")
.get(payload.boundary.admission.logicalTurnId),
).toBeDefined();
});
it("does not let later same-session turns overtake a failed commit", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-order-"));
tempDirs.push(stateDir);
const database = openOpenClawAgentDatabase({
agentId: "main",
env: { OPENCLAW_STATE_DIR: stateDir },
});
const enqueue = (advancementKey: string, sessionId: string, sequence: number) =>
enqueueContextEngineTurnCommit({
database,
engineId: "test",
payload: createPayload({
advancementKey,
databasePath: database.path,
sequence,
sessionId,
}),
});
enqueue("session-a:z-first", "session-a", 1);
for (let turn = 2; turn <= 17; turn += 1) {
enqueue(turn === 2 ? "session-a:a-second" : `session-a:${turn}`, "session-a", turn * 2 - 1);
}
enqueue("session-b:1", "session-b", 1);
database.db.exec(`
UPDATE context_engine_turn_outbox SET created_at = CASE
WHEN session_id = 'session-a' THEN 1
ELSE 100
END;
`);
let failFirstTurn = true;
const commitTurn = vi.fn(async ({ advancementKey }: { advancementKey: string }) => {
if (advancementKey === "session-a:z-first" && failFirstTurn) {
throw new Error("temporary failure");
}
return { status: "committed" as const };
});
const engine = {
info: { id: "test", name: "Test" },
ingest: async () => ({ ingested: true }),
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
compact: async () => ({ ok: true, compacted: false }),
commitTurn,
} satisfies ContextEngine;
const warn = vi.fn();
await drainContextEngineTurnOutbox({
database,
engine,
engineId: "test",
warn,
});
expect(commitTurn.mock.calls.map(([call]) => call.advancementKey)).toEqual([
"session-a:z-first",
"session-b:1",
]);
failFirstTurn = false;
await drainContextEngineTurnOutbox({
database,
engine,
engineId: "test",
limit: 2,
warn,
});
expect(commitTurn.mock.calls.map(([call]) => call.advancementKey)).toEqual([
"session-a:z-first",
"session-b:1",
"session-a:z-first",
"session-a:a-second",
]);
await drainContextEngineTurnOutbox({
database,
engine,
engineId: "test",
limit: 1,
warn,
});
expect(commitTurn.mock.calls.map(([call]) => call.advancementKey)).toEqual([
"session-a:z-first",
"session-b:1",
"session-a:z-first",
"session-a:a-second",
"session-a:3",
]);
});
it("retries the current session before the next run and degrades if it stays blocked", async () => {
const stateDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-context-outbox-retry-"));
tempDirs.push(stateDir);
const database = openOpenClawAgentDatabase({
agentId: "main",
env: { OPENCLAW_STATE_DIR: stateDir },
});
const payload = createPayload({
advancementKey: "session-a:retry",
databasePath: database.path,
sequence: 1,
sessionId: "session-a",
});
enqueueContextEngineTurnCommit({ database, engineId: "test", payload });
let blocked = true;
const commitTurn = vi.fn(async () => {
if (blocked) {
throw new Error("temporary failure");
}
return { status: "committed" as const };
});
const engine = {
info: {
id: "test",
name: "Test",
transcriptSemantics: {
currentTurnFence: "before-current-turn-entry-v1",
turnAdvancementIdempotency: "atomic-idempotent-v1",
},
},
ingest: async () => ({ ingested: true }),
assemble: async ({ messages }) => ({ messages, estimatedTokens: 0 }),
compact: async () => ({ ok: true, compacted: false }),
commitTurn,
} satisfies ContextEngine;
const degradeBeforeStart = vi.fn();
const lease = {
engine,
effectiveEngine: engine,
effectiveEngineId: "test",
effectiveEnginePluginId: undefined,
degraded: false,
degradedReason: undefined,
selectForHost: vi.fn(),
degradeBeforeStart,
begin: vi.fn(),
deferDisposalUntil: vi.fn(),
dispose: vi.fn(async () => undefined),
} satisfies ContextEngineLogicalTurnLease;
const warn = vi.fn();
await drainContextEngineTurnOutbox({ database, engine, engineId: "test", warn });
blocked = false;
await drainPendingContextEngineTurnsBeforeRun({
admission: payload.boundary.admission,
lease,
warn,
});
expect(commitTurn).toHaveBeenCalledTimes(2);
expect(degradeBeforeStart).not.toHaveBeenCalled();
enqueueContextEngineTurnCommit({
database,
engineId: "test",
payload: createPayload({
advancementKey: "session-a:blocked",
databasePath: database.path,
sequence: 3,
sessionId: "session-a",
}),
});
blocked = true;
await drainPendingContextEngineTurnsBeforeRun({
admission: payload.boundary.admission,
lease,
warn,
});
expect(degradeBeforeStart).toHaveBeenCalledWith(
"pending durable turn advancement could not be completed before the next turn",
);
});
});
@@ -0,0 +1,479 @@
import { sql } from "kysely";
import type { AgentMessage } from "../../../packages/agent-core/src/types.js";
import {
readClosedTranscriptTurn,
type ClosedTranscriptTurnReadResult,
type TranscriptTurnBoundary,
} from "../../config/sessions/session-accessor.js";
import type { TranscriptTurnAdmission } from "../../config/sessions/transcript-entry-anchor.js";
import type { ContextEngine } from "../../context-engine/types.js";
import {
executeSqliteQuerySync,
executeSqliteQueryTakeFirstSync,
getNodeSqliteKysely,
} from "../../infra/kysely-sync.js";
import { ensureContextEngineTurnOutboxSchema } from "../../state/openclaw-agent-context-engine-turn-outbox-schema.js";
import type { DB as OpenClawAgentDatabaseSchema } from "../../state/openclaw-agent-db.generated.js";
import type { OpenClawAgentDatabase } from "../../state/openclaw-agent-db.js";
type ContextEngineTurnOutboxDatabase = Pick<
OpenClawAgentDatabaseSchema,
"context_engine_turn_outbox"
>;
type PendingContextEngineTurn = Readonly<{
advancement_key: string;
payload_json: string;
session_id: string;
}>;
type AdmittedContextEngineTurnOutboxPayload = Readonly<{
admission: TranscriptTurnAdmission;
isHeartbeat: boolean;
state: "admitted";
}>;
type AcceptedContextEngineTurnOutboxPayload = Readonly<{
boundary: TranscriptTurnBoundary;
isHeartbeat: boolean;
state: "accepted";
}>;
type ReadyContextEngineTurnOutboxPayload = Readonly<{
boundary: TranscriptTurnBoundary;
isHeartbeat: boolean;
messages: AgentMessage[];
prePromptMessageCount: number;
state: "ready";
}>;
type ContextEngineTurnReadFailureKind = Exclude<
ClosedTranscriptTurnReadResult,
{ kind: "ok" }
>["kind"];
type BlockedContextEngineTurnOutboxPayload = Readonly<{
boundary: TranscriptTurnBoundary;
failure: Exclude<ContextEngineTurnReadFailureKind, "projection-unavailable">;
isHeartbeat: boolean;
state: "blocked";
}>;
type ContextEngineTurnOutboxPayload =
| AdmittedContextEngineTurnOutboxPayload
| AcceptedContextEngineTurnOutboxPayload
| BlockedContextEngineTurnOutboxPayload
| ReadyContextEngineTurnOutboxPayload;
const RECOVERED_TURN_MAX_EVENTS = 20_000;
const RECOVERED_TURN_MAX_BYTES = 8 * 1024 * 1024;
function outboxEnqueueSequence() {
return /* kysely-allow-raw: SQLite's implicit rowid is the durable enqueue sequence for this table. */ sql<number>`context_engine_turn_outbox.rowid`;
}
function oldestOutboxEnqueueSequence() {
return /* kysely-allow-raw: Aggregate the closed implicit-rowid expression used for enqueue order. */ sql<number>`MIN(context_engine_turn_outbox.rowid)`;
}
export function isRetryableContextEngineTurnReadFailure(
kind: ContextEngineTurnReadFailureKind,
): kind is "projection-unavailable" {
return kind === "projection-unavailable";
}
function outboxDb(database: OpenClawAgentDatabase) {
ensureContextEngineTurnOutboxSchema(database.db);
return getNodeSqliteKysely<ContextEngineTurnOutboxDatabase>(database.db);
}
function assertMatchingOutboxOwner(
existing: { engine_id: string; owner_plugin_id: string | null },
params: { engineId: string; ownerPluginId?: string },
advancementKey: string,
): void {
if (
existing.engine_id !== params.engineId ||
existing.owner_plugin_id !== (params.ownerPluginId ?? null)
) {
throw new Error(`context-engine advancement key collision: ${advancementKey}`);
}
}
function writeContextEngineTurnOutboxPayload(params: {
database: OpenClawAgentDatabase;
engineId: string;
ownerPluginId?: string;
payload: ContextEngineTurnOutboxPayload;
}): void {
const db = outboxDb(params.database);
const admission =
params.payload.state === "admitted"
? params.payload.admission
: params.payload.boundary.admission;
const advancementKey = admission.logicalTurnId;
const payloadJson = JSON.stringify(params.payload);
const existing = executeSqliteQueryTakeFirstSync(
params.database.db,
db
.selectFrom("context_engine_turn_outbox")
.select(["engine_id", "owner_plugin_id", "payload_json"])
.where("advancement_key", "=", advancementKey),
);
if (existing) {
assertMatchingOutboxOwner(existing, params, advancementKey);
const existingPayload = JSON.parse(existing.payload_json) as ContextEngineTurnOutboxPayload;
const transitionMatches =
(params.payload.state === "accepted" &&
existingPayload.state === "admitted" &&
existingPayload.admission.entryId === admission.entryId) ||
(params.payload.state === "blocked" &&
existingPayload.state === "accepted" &&
existingPayload.boundary.admission.entryId === admission.entryId &&
existingPayload.boundary.terminal.entryId === params.payload.boundary.terminal.entryId) ||
(params.payload.state === "ready" &&
existingPayload.state === "accepted" &&
existingPayload.boundary.admission.entryId === admission.entryId &&
existingPayload.boundary.terminal.entryId === params.payload.boundary.terminal.entryId);
if (transitionMatches) {
executeSqliteQuerySync(
params.database.db,
db
.updateTable("context_engine_turn_outbox")
.set({
attempt_count: 0,
last_attempt_at: null,
last_error: null,
payload_json: payloadJson,
})
.where("advancement_key", "=", advancementKey),
);
return;
}
if (existing.payload_json !== payloadJson) {
throw new Error(`context-engine advancement key collision: ${advancementKey}`);
}
return;
}
executeSqliteQuerySync(
params.database.db,
db
.insertInto("context_engine_turn_outbox")
.values({
advancement_key: advancementKey,
engine_id: params.engineId,
owner_plugin_id: params.ownerPluginId ?? null,
session_id: admission.sessionId,
payload_json: payloadJson,
created_at: Date.now(),
last_attempt_at: null,
last_error: null,
})
.onConflict((conflict) => conflict.column("advancement_key").doNothing()),
);
}
export function enqueueContextEngineTurnIntent(params: {
admission: TranscriptTurnAdmission;
database: OpenClawAgentDatabase;
engineId: string;
isHeartbeat: boolean;
ownerPluginId?: string;
}): void {
writeContextEngineTurnOutboxPayload({
...params,
payload: {
admission: params.admission,
isHeartbeat: params.isHeartbeat,
state: "admitted",
},
});
}
export function acceptContextEngineTurnIntent(params: {
boundary: TranscriptTurnBoundary;
database: OpenClawAgentDatabase;
engineId: string;
isHeartbeat: boolean;
ownerPluginId?: string;
}): void {
writeContextEngineTurnOutboxPayload({
...params,
payload: {
boundary: params.boundary,
isHeartbeat: params.isHeartbeat,
state: "accepted",
},
});
}
export function enqueueContextEngineTurnCommit(params: {
database: OpenClawAgentDatabase;
engineId: string;
ownerPluginId?: string;
payload: Omit<ReadyContextEngineTurnOutboxPayload, "state">;
}): void {
writeContextEngineTurnOutboxPayload({
...params,
payload: { ...params.payload, state: "ready" },
});
}
export function blockContextEngineTurnIntent(params: {
boundary: TranscriptTurnBoundary;
database: OpenClawAgentDatabase;
engineId: string;
failure: BlockedContextEngineTurnOutboxPayload["failure"];
isHeartbeat: boolean;
ownerPluginId?: string;
}): void {
writeContextEngineTurnOutboxPayload({
...params,
payload: {
boundary: params.boundary,
failure: params.failure,
isHeartbeat: params.isHeartbeat,
state: "blocked",
},
});
}
export function discardContextEngineTurnIntent(params: {
admission: TranscriptTurnAdmission;
database: OpenClawAgentDatabase;
engineId: string;
ownerPluginId?: string;
}): void {
const db = outboxDb(params.database);
executeSqliteQuerySync(
params.database.db,
db
.deleteFrom("context_engine_turn_outbox")
.where("advancement_key", "=", params.admission.logicalTurnId)
.where("engine_id", "=", params.engineId)
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null),
);
}
export function recoverContextEngineTurnOutbox(params: {
currentAdmission: TranscriptTurnAdmission;
database: OpenClawAgentDatabase;
engineId: string;
ownerPluginId?: string;
warn: (message: string) => void;
}): void {
const db = outboxDb(params.database);
const rows = executeSqliteQuerySync(
params.database.db,
db
.selectFrom("context_engine_turn_outbox")
.select(["advancement_key", "payload_json"])
.where("engine_id", "=", params.engineId)
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null)
.where("session_id", "=", params.currentAdmission.sessionId)
.orderBy(outboxEnqueueSequence(), "asc"),
).rows;
for (const row of rows) {
const payload = JSON.parse(row.payload_json) as ContextEngineTurnOutboxPayload;
if (payload.state === "ready") {
continue;
}
if (payload.state === "blocked") {
params.warn(
`[context-engine] durable turn advancement is blocked: ${row.advancement_key}: transcript range is ${payload.failure}`,
);
continue;
}
if (payload.state === "admitted") {
// Admission proves provider dispatch only. Without the host-owned accepted
// transition, later descendants may belong to a rejected fallback attempt.
discardContextEngineTurnIntent({
admission: payload.admission,
database: params.database,
engineId: params.engineId,
ownerPluginId: params.ownerPluginId,
});
continue;
}
const closedTurn = readClosedTranscriptTurn({
boundary: payload.boundary,
maxEvents: RECOVERED_TURN_MAX_EVENTS,
maxBytes: RECOVERED_TURN_MAX_BYTES,
});
if (closedTurn.kind !== "ok") {
if (isRetryableContextEngineTurnReadFailure(closedTurn.kind)) {
params.warn(
`[context-engine] durable turn recovery remains queued: ${row.advancement_key}: transcript range is ${closedTurn.kind}`,
);
continue;
}
params.warn(
`[context-engine] blocked unrecoverable turn advancement: ${row.advancement_key}: transcript range is ${closedTurn.kind}`,
);
blockContextEngineTurnIntent({
boundary: payload.boundary,
database: params.database,
engineId: params.engineId,
failure: closedTurn.kind,
isHeartbeat: payload.isHeartbeat,
ownerPluginId: params.ownerPluginId,
});
continue;
}
enqueueContextEngineTurnCommit({
database: params.database,
engineId: params.engineId,
ownerPluginId: params.ownerPluginId,
payload: {
boundary: payload.boundary,
isHeartbeat: payload.isHeartbeat,
messages: closedTurn.messages,
prePromptMessageCount: closedTurn.prePromptMessageCount,
},
});
}
}
export async function drainContextEngineTurnOutbox(params: {
database: OpenClawAgentDatabase;
engine: ContextEngine;
engineId: string;
ownerPluginId?: string;
sessionId?: string;
limit?: number;
warn: (message: string) => void;
}): Promise<{ pending: boolean }> {
const commitTurn = params.engine.commitTurn?.bind(params.engine);
if (typeof commitTurn !== "function") {
return { pending: false };
}
let remaining = Math.max(0, params.limit ?? 16);
if (remaining === 0) {
return { pending: hasPendingContextEngineTurn(params) };
}
const db = outboxDb(params.database);
let pendingSessionsQuery = db
.selectFrom("context_engine_turn_outbox")
.select("session_id")
// SQLite rowid preserves enqueue order among surviving pending rows.
// Use it instead of wall-clock timestamps, which can collide.
.select(oldestOutboxEnqueueSequence().as("oldest_enqueue_sequence"))
.where("engine_id", "=", params.engineId)
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null);
if (params.sessionId) {
pendingSessionsQuery = pendingSessionsQuery.where("session_id", "=", params.sessionId);
}
const pendingSessions = executeSqliteQuerySync(
params.database.db,
pendingSessionsQuery
.groupBy("session_id")
.orderBy("oldest_enqueue_sequence", "asc")
.limit(remaining),
).rows;
let activeSessionIds = pendingSessions.map(({ session_id }) => session_id);
while (remaining > 0 && activeSessionIds.length > 0) {
const continuingSessionIds: string[] = [];
for (const sessionId of activeSessionIds) {
if (remaining === 0) {
break;
}
const row = executeSqliteQueryTakeFirstSync(
params.database.db,
db
.selectFrom("context_engine_turn_outbox")
.select(["advancement_key", "payload_json", "session_id"])
.where("engine_id", "=", params.engineId)
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null)
.where("session_id", "=", sessionId)
.orderBy(outboxEnqueueSequence(), "asc")
.limit(1),
);
if (!row) {
continue;
}
remaining -= 1;
if (await commitPendingContextEngineTurn({ ...params, commitTurn, db, row })) {
continuingSessionIds.push(sessionId);
}
}
activeSessionIds = continuingSessionIds;
}
return { pending: hasPendingContextEngineTurn(params) };
}
function hasPendingContextEngineTurn(
params: Pick<
Parameters<typeof drainContextEngineTurnOutbox>[0],
"database" | "engineId" | "ownerPluginId" | "sessionId"
>,
): boolean {
const db = outboxDb(params.database);
let query = db
.selectFrom("context_engine_turn_outbox")
.select("advancement_key")
.where("engine_id", "=", params.engineId)
.where("owner_plugin_id", params.ownerPluginId ? "=" : "is", params.ownerPluginId ?? null);
if (params.sessionId) {
query = query.where("session_id", "=", params.sessionId);
}
return executeSqliteQueryTakeFirstSync(params.database.db, query.limit(1)) !== undefined;
}
async function commitPendingContextEngineTurn(
params: Omit<Parameters<typeof drainContextEngineTurnOutbox>[0], "limit" | "sessionId"> & {
commitTurn: NonNullable<ContextEngine["commitTurn"]>;
db: ReturnType<typeof outboxDb>;
row: PendingContextEngineTurn;
},
): Promise<boolean> {
const { row } = params;
try {
const payload = JSON.parse(row.payload_json) as ContextEngineTurnOutboxPayload;
if (payload.state !== "ready") {
return false;
}
const result = await params.commitTurn({
advancementKey: row.advancement_key,
admission: payload.boundary.admission,
terminal: payload.boundary.terminal,
messages: payload.messages,
prePromptMessageCount: payload.prePromptMessageCount,
sessionId: payload.boundary.admission.sessionId,
sessionKey: payload.boundary.admission.sessionKey,
sessionTarget: {
agentId: payload.boundary.admission.agentId,
sessionId: payload.boundary.admission.sessionId,
sessionKey: payload.boundary.admission.sessionKey,
storePath: payload.boundary.admission.storePath,
},
isHeartbeat: payload.isHeartbeat,
});
if (result.status !== "committed" && result.status !== "duplicate") {
throw new Error(`invalid commitTurn result status: ${String(result.status)}`);
}
executeSqliteQuerySync(
params.database.db,
params.db
.deleteFrom("context_engine_turn_outbox")
.where("advancement_key", "=", row.advancement_key),
);
return true;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
executeSqliteQuerySync(
params.database.db,
params.db
.updateTable("context_engine_turn_outbox")
.set((eb) => ({
attempt_count: eb("attempt_count", "+", 1),
last_attempt_at: Date.now(),
last_error: message,
}))
.where("advancement_key", "=", row.advancement_key),
);
params.warn(
`[context-engine] durable turn advancement remains queued: ${row.advancement_key}: ${message}`,
);
return false;
}
}
+194 -1
View File
@@ -2,10 +2,12 @@
import type { Model } from "openclaw/plugin-sdk/llm";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../config/config.js";
import type { TranscriptEntryAnchor } from "../../config/sessions/transcript-entry-anchor.js";
import { OPENCLAW_EMBEDDED_CONTEXT_ENGINE_HOST } from "../../context-engine/host-compat.js";
import type { ContextEngine } from "../../context-engine/types.js";
import { createOpenClawCodingTools } from "../../plugin-sdk/agent-harness.js";
import { mintSecretSentinel } from "../../secrets/sentinel.js";
import type { UserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.types.js";
import { createAgentExecutionAttribution } from "../agent-execution-attribution.js";
import { isHostScopedAgentToolActive } from "../agent-tools.ring-zero-context.js";
import { testing as cliBackendsTesting } from "../cli-backends.test-support.js";
@@ -19,6 +21,7 @@ import type {
} from "../embedded-agent-runner/run/types.js";
import type { SystemAgentToolOptions } from "../tools/system-agent-tool.js";
import { maybeCompactAgentHarnessSession } from "./compaction.js";
import type { ContextEngineLogicalTurnLease } from "./context-engine-logical-turn.js";
import { clearAgentHarnesses, registerAgentHarness } from "./registry.js";
import {
agentHarnessBuildsOpenClawTools,
@@ -55,6 +58,33 @@ const compactAuthMocks = vi.hoisted(() => ({
const providerOwnerMocks = vi.hoisted(() => ({
resolveProviderRefOwnership: vi.fn(),
}));
const contextEngineTurnAttemptMocks = vi.hoisted(() => ({
drainPendingContextEngineTurnsBeforeRun: vi.fn(async (_params: unknown) => {}),
}));
function createTranscriptRecorder(
admission: ReturnType<typeof createTranscriptAnchor> & {
logicalTurnId: string;
role: "user";
},
): UserTurnTranscriptRecorder {
const message = { role: "user" as const, content: "hello", timestamp: 1 };
return {
message,
resolveMessage: async () => message,
getAdmissionReceipt: () => admission,
markRuntimePersistencePending: () => {},
markRuntimePersisted: () => {},
markBlocked: () => {},
hasPersisted: () => true,
isBlocked: () => false,
hasRuntimePersistencePending: () => false,
waitForRuntimePersistence: async () => {},
persistApproved: async () => undefined,
persistBlocked: async () => undefined,
persistFallback: async () => undefined,
};
}
it("identifies harnesses that expose OpenClaw tools", () => {
expect(agentHarnessBuildsOpenClawTools("openclaw")).toBe(false);
@@ -98,6 +128,10 @@ vi.mock("../runtime-plan/prepare-auth.js", async (importOriginal) => {
vi.mock("../../plugins/providers.js", () => ({
resolveProviderRefOwnership: providerOwnerMocks.resolveProviderRefOwnership,
}));
vi.mock("./context-engine-turn-attempt.js", () => ({
drainPendingContextEngineTurnsBeforeRun:
contextEngineTurnAttemptMocks.drainPendingContextEngineTurnsBeforeRun,
}));
const originalRuntime = process.env.OPENCLAW_AGENT_RUNTIME;
@@ -114,6 +148,9 @@ beforeEach(() => {
compactAuthMocks.getApiKeyForModel.mockResolvedValue({ apiKey: "test-key" });
providerOwnerMocks.resolveProviderRefOwnership.mockReset();
providerOwnerMocks.resolveProviderRefOwnership.mockReturnValue({ status: "unowned" });
contextEngineTurnAttemptMocks.drainPendingContextEngineTurnsBeforeRun
.mockReset()
.mockResolvedValue(undefined);
cliBackendsTesting.setDepsForTest({
resolvePluginSetupRegistry: () => ({
providers: [],
@@ -149,6 +186,7 @@ afterEach(() => {
compactAuthMocks.ensureAuthProfileStore.mockReset();
compactAuthMocks.ensureAuthProfileStoreWithoutExternalProfiles.mockReset();
providerOwnerMocks.resolveProviderRefOwnership.mockReset();
contextEngineTurnAttemptMocks.drainPendingContextEngineTurnsBeforeRun.mockReset();
if (originalRuntime == null) {
delete process.env.OPENCLAW_AGENT_RUNTIME;
} else {
@@ -193,6 +231,24 @@ function createAttemptResult(sessionIdUsed: string): EmbeddedRunAttemptResult {
};
}
function createTranscriptAnchor(
entryId: string,
rawSeq: number,
activeMessagePosition: number,
): TranscriptEntryAnchor {
return {
agentId: "main",
sessionId: "session-1",
sessionKey: "agent:main:session-1",
storePath: "/tmp/openclaw-agent.sqlite",
generation: "generation-1",
entryId,
effectiveParentId: rawSeq === 1 ? null : "user-1",
rawSeq,
activeMessagePosition,
};
}
function createFinalAssistant(): NonNullable<EmbeddedRunAttemptResult["lastAssistant"]> {
return {
role: "assistant",
@@ -512,6 +568,143 @@ describe("runAgentHarnessAttempt", () => {
},
);
it.each(["heartbeat", "commitment-only"] as const)(
"records %s classification on the host-owned turn candidate",
async (bootstrapContextRunKind) => {
const admission = {
...createTranscriptAnchor("user-1", 1, 0),
logicalTurnId: "heartbeat-turn",
role: "user" as const,
};
const terminal = createTranscriptAnchor("assistant-1", 2, 1);
const onContextEngineTurnCandidate = vi.fn();
registerAgentHarness(
{
id: "codex",
label: "Codex",
supports: () => ({ supported: true, priority: 100 }),
runAttempt: async () => ({
...createAttemptResult("session-1"),
contextEngineTerminalAnchor: terminal,
}),
},
{ ownerPluginId: "codex" },
);
const params = createAttemptParams(providerRuntimeConfig("codex", "codex"));
params.agentHarnessRuntimeOverride = "codex";
params.sessionKey = admission.sessionKey;
params.sessionTarget = {
agentId: admission.agentId,
sessionId: admission.sessionId,
sessionKey: admission.sessionKey,
storePath: admission.storePath,
};
params.bootstrapContextRunKind = bootstrapContextRunKind;
params.userTurnTranscriptRecorder = createTranscriptRecorder(admission);
params.onContextEngineTurnCandidate = onContextEngineTurnCandidate;
await runAgentHarnessAttempt(params);
expect(onContextEngineTurnCandidate).toHaveBeenCalledWith(
expect.objectContaining({
boundary: { admission, terminal },
harnessId: "codex",
isHeartbeat: true,
promptError: false,
aborted: false,
yieldAborted: false,
}),
);
},
);
it("drains pending context-engine turns before pinning a plugin harness", async () => {
const order: string[] = [];
const configuredEngine = createContextEngineRequiringAssembly();
const fallbackEngine = {
...configuredEngine,
info: { id: "legacy", name: "Legacy" },
} satisfies ContextEngine;
let effectiveEngine = configuredEngine;
let degradedReason: string | undefined;
const asEffective = (): ReturnType<ContextEngineLogicalTurnLease["begin"]> => ({
engine: effectiveEngine,
registeredId: effectiveEngine.info.id,
mode: degradedReason ? "legacy-degraded" : "configured",
...(degradedReason ? { reason: degradedReason } : {}),
});
const lease = {
get engine() {
return effectiveEngine;
},
get effectiveEngine() {
return effectiveEngine;
},
get effectiveEngineId() {
return effectiveEngine.info.id;
},
get effectiveEnginePluginId() {
return undefined;
},
get degraded() {
return degradedReason !== undefined;
},
get degradedReason() {
return degradedReason;
},
selectForHost: vi.fn(() => asEffective()),
degradeBeforeStart: vi.fn((reason: string) => {
degradedReason = reason;
effectiveEngine = fallbackEngine;
return asEffective();
}),
begin: vi.fn(() => {
order.push("begin");
return asEffective();
}),
deferDisposalUntil: vi.fn(),
dispose: vi.fn(async () => {}),
} satisfies ContextEngineLogicalTurnLease;
contextEngineTurnAttemptMocks.drainPendingContextEngineTurnsBeforeRun.mockImplementationOnce(
async (params) => {
const { lease: drainLease } = params as { lease: ContextEngineLogicalTurnLease };
order.push("drain");
drainLease.degradeBeforeStart("pending durable turn advancement is blocked");
},
);
const receivedContextEngines: Array<ContextEngine | undefined> = [];
registerAgentHarness(
{
id: "codex",
label: "Codex",
supports: () => ({ supported: true, priority: 100 }),
runAttempt: async (attemptParams) => {
order.push("run");
receivedContextEngines.push(attemptParams.contextEngine);
return createAttemptResult("session-1");
},
},
{ ownerPluginId: "codex" },
);
const admission = {
...createTranscriptAnchor("user-1", 1, 0),
logicalTurnId: "turn-1",
role: "user" as const,
};
const params = createAttemptParams(providerRuntimeConfig("codex", "codex"));
params.agentHarnessRuntimeOverride = "codex";
params.contextEngineLogicalTurnLease = lease;
params.userTurnTranscriptRecorder = createTranscriptRecorder(admission);
await runAgentHarnessAttempt(params);
expect(
contextEngineTurnAttemptMocks.drainPendingContextEngineTurnsBeforeRun,
).toHaveBeenCalledWith({ admission, isHeartbeat: false, lease });
expect(order).toEqual(["drain", "begin", "run"]);
expect(receivedContextEngines).toEqual([undefined]);
});
it.each([
{ name: "missing", toolsAllow: undefined },
{ name: "broad", toolsAllow: ["openclaw", "read"] },
@@ -921,7 +1114,7 @@ describe("runAgentHarnessAttempt", () => {
const classifyCall = classify.mock.calls.at(0);
expect(classifyCall?.[0].sessionIdUsed).toBe("codex");
expect(classifyCall?.[1]).toBe(params);
expect(classifyCall?.[1]).toStrictEqual(params);
expect(result.agentHarnessId).toBe("codex");
expect(result.agentHarnessResultClassification).toBe("empty");
});
+74 -6
View File
@@ -18,6 +18,7 @@ import {
isHostScopedAgentToolActive,
runWithAgentRingZeroTools,
} from "../agent-tools.ring-zero-context.js";
import { isHeartbeatLifecycleRunKind } from "../bootstrap-mode.js";
import { resolveConversationCapabilityProfile } from "../conversation-capability-profile.js";
import { transferEmbeddedAttemptExecutionAttribution } from "../embedded-agent-runner/run/attempt-execution-attribution.js";
import type {
@@ -34,6 +35,8 @@ import { expandToolGroups, mergeAlsoAllowPolicy, normalizeToolName } from "../to
import type { SystemAgentToolOptions } from "../tools/system-agent-tool.js";
import { resolveAgentHarnessAutoSelectionHint } from "./auto-selection.js";
import { createOpenClawAgentHarness } from "./builtin-openclaw.js";
import { selectContextEngineForTranscriptHost } from "./context-engine-logical-turn.js";
import { drainPendingContextEngineTurnsBeforeRun } from "./context-engine-turn-attempt.js";
import { MissingAgentHarnessError } from "./errors.js";
import {
runAgentHarnessLifecycleAttempt,
@@ -499,11 +502,33 @@ export async function runAgentHarnessSettledTurnFinalization(
async function runSelectedAgentHarnessAttempt(
params: EmbeddedRunAttemptParams,
): Promise<EmbeddedRunAttemptResult> {
const internalParams = params as EmbeddedRunAttemptParams & {
let internalParams = params as EmbeddedRunAttemptParams & {
systemAgentTool?: SystemAgentToolOptions;
};
const selection = selectPreparedAgentHarness(params);
const harness = selection.harness;
if (internalParams.contextEngineLogicalTurnLease) {
selectContextEngineForTranscriptHost({
lease: internalParams.contextEngineLogicalTurnLease,
host: {
id: `agent-harness:${harness.id}`,
label: `agent harness "${harness.id}"`,
capabilities: harness.contextEngineHostCapabilities ?? [],
},
operation: "agent-run",
recorder: internalParams.userTurnTranscriptRecorder,
});
await drainPendingContextEngineTurnsBeforeRun({
admission: internalParams.userTurnTranscriptRecorder?.getAdmissionReceipt(),
isHeartbeat: isHeartbeatLifecycleRunKind(internalParams.bootstrapContextRunKind),
lease: internalParams.contextEngineLogicalTurnLease,
});
const effective = internalParams.contextEngineLogicalTurnLease.begin();
internalParams = {
...internalParams,
contextEngine: effective.engine.info.id === "legacy" ? undefined : effective.engine,
};
}
if (internalParams.systemAgentTool && !isSystemAgentOnlyAllowlist(internalParams.toolsAllow)) {
throw new Error('OpenClaw host authority requires toolsAllow: ["openclaw"]');
}
@@ -521,7 +546,7 @@ async function runSelectedAgentHarnessAttempt(
sessionKey: params.sessionKey,
agentId: params.agentId,
});
return runAgentHarnessOperation(harness, params, () =>
const result = await runAgentHarnessOperation(harness, params, () =>
runWithAgentRingZeroTools(ringZeroTools, () => {
// Resolve plugin policy after entering the host scope. Ring-zero tools are
// trusted setup authority and must survive ordinary deny-all policy.
@@ -530,6 +555,47 @@ async function runSelectedAgentHarnessAttempt(
return runAgentHarnessLifecycleAttempt(harness, attemptParams);
}),
);
const admission = internalParams.userTurnTranscriptRecorder?.getAdmissionReceipt();
if (
internalParams.onContextEngineTurnCandidate &&
admission &&
result.contextEngineTerminalAnchor
) {
internalParams.onContextEngineTurnCandidate({
boundary: {
admission,
terminal: result.contextEngineTerminalAnchor,
},
sessionIdUsed: result.sessionIdUsed,
sessionKey: internalParams.sessionKey,
sessionTarget: internalParams.sessionTarget,
sessionFile: result.sessionFileUsed ?? internalParams.sessionFile,
promptError: result.terminal.kind === "failed",
aborted:
result.terminal.kind === "aborted" ||
(result.terminal.kind === "timeout" &&
"aborted" in result.terminal &&
result.terminal.aborted === true),
yieldAborted:
result.terminal.kind === "aborted" && result.terminal.source === "yield_cleanup",
isHeartbeat: isHeartbeatLifecycleRunKind(internalParams.bootstrapContextRunKind),
tokenBudget: internalParams.contextTokenBudget,
contextEngineHostSupport: {
id: `agent-harness:${harness.id}`,
label: `agent harness "${harness.id}"`,
capabilities: harness.contextEngineHostCapabilities ?? [],
},
harnessId: harness.id,
providerId: internalParams.provider,
requestedModelId: internalParams.requestedModelId,
modelId: internalParams.modelId,
fallbackReason: internalParams.fallbackReason,
degradedReason: internalParams.degradedReason,
config: internalParams.config,
});
}
const { contextEngineTerminalAnchor: _contextEngineTerminalAnchor, ...publicResult } = result;
return publicResult;
}
function selectPreparedAgentHarness(
@@ -586,10 +652,12 @@ function isSystemAgentOnlyAllowlist(toolsAllow: readonly string[] | undefined):
function withoutInternalHarnessAuthority(
params: EmbeddedRunAttemptParams & { systemAgentTool?: SystemAgentToolOptions },
): EmbeddedRunAttemptParams {
if (!Object.hasOwn(params, "systemAgentTool")) {
return params;
}
const { systemAgentTool: _systemAgentTool, ...pluginParams } = params;
const {
contextEngineLogicalTurnLease: _contextEngineLogicalTurnLease,
onContextEngineTurnCandidate: _onContextEngineTurnCandidate,
systemAgentTool: _systemAgentTool,
...pluginParams
} = params;
return transferEmbeddedAttemptExecutionAttribution(params, pluginParams);
}
+7 -5
View File
@@ -63,14 +63,16 @@ type AgentHarnessDeprecatedAttemptTerminalFields = {
| import("../agent-run-terminal-outcome.js").AgentRunAttemptFailureSource
| null;
};
type AgentHarnessCanonicalAttemptResult =
import("../embedded-agent-runner/run/types.js").EmbeddedRunAttemptResult &
AgentHarnessDeprecatedAttemptTerminalFields;
type AgentHarnessCanonicalAttemptResult = Omit<
import("../embedded-agent-runner/run/types.js").EmbeddedRunAttemptResult,
"contextEngineTerminalAnchor"
> &
AgentHarnessDeprecatedAttemptTerminalFields;
/** @deprecated Return `terminal` instead. Remove no earlier than the 2026.9 stable release. */
type AgentHarnessLegacyAttemptResult = Omit<
import("../embedded-agent-runner/run/types.js").EmbeddedRunAttemptResult,
"terminal"
"contextEngineTerminalAnchor" | "terminal"
> &
AgentHarnessDeprecatedAttemptTerminalFields & {
aborted: boolean;
@@ -88,7 +90,7 @@ type AgentHarnessLegacyAttemptResult = Omit<
export type AgentHarnessAttemptParams = Omit<
InternalEmbeddedRunAttemptParams,
"trajectoryRecorder"
"contextEngineLogicalTurnLease" | "onContextEngineTurnCandidate" | "trajectoryRecorder"
>;
export type AgentHarnessAttemptResult =
| AgentHarnessCanonicalAttemptResult
+25
View File
@@ -194,6 +194,31 @@ describe("live model switch", () => {
});
});
it.each([
{
name: "legacy source-less user",
authProfileOverrideCompactionCount: undefined,
expectedSource: "user",
},
{
name: "legacy source-less automatic",
authProfileOverrideCompactionCount: 0,
expectedSource: "auto",
},
])("projects $name auth provenance", ({ authProfileOverrideCompactionCount, expectedSource }) => {
expect(
resolvePendingSelection({
providerOverride: "openai",
modelOverride: "gpt-5.4",
authProfileOverride: "profile-gpt",
authProfileOverrideCompactionCount,
}),
).toMatchObject({
authProfileId: "profile-gpt",
authProfileIdSource: expectedSource,
});
});
it("prefers persisted session overrides ahead of stale runtime model fields", () => {
expect(
resolvePendingSelection(
+2 -1
View File
@@ -3,6 +3,7 @@
*/
import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveSessionAuthProfileOverrideSource } from "../config/sessions/auth-profile-override-provenance.js";
import { resolveStorePath } from "../config/sessions/paths.js";
import {
loadSessionEntry,
@@ -109,7 +110,7 @@ function resolveSelectionFromSessionEntry(params: {
model,
...(agentRuntimeOverride ? { agentRuntimeOverride } : {}),
authProfileId,
authProfileIdSource: authProfileId ? entry?.authProfileOverrideSource : undefined,
authProfileIdSource: authProfileId ? resolveSessionAuthProfileOverrideSource(entry) : undefined,
};
}
+70 -18
View File
@@ -5,7 +5,7 @@ import { emitFailoverEvent } from "../infra/diagnostic-events.js";
import { formatErrorMessage } from "../infra/errors.js";
import { createSubsystemLogger } from "../logging/subsystem.js";
import { createLazyImportLoader } from "../shared/lazy-promise.js";
import { externalCliDiscoveryForProviders } from "./auth-profiles/external-cli-discovery.js";
import { externalCliDiscoveryScoped } from "./auth-profiles/external-cli-discovery.js";
import { resolveSubscriptionAuthModeForProfiles } from "./auth-profiles/profile-list.js";
import { hasAnyAuthProfileStoreSource } from "./auth-profiles/source-check.js";
import { isLikelyContextOverflowError } from "./embedded-agent-helpers/errors.js";
@@ -70,7 +70,11 @@ import {
logModelFallbackDecision,
type ModelFallbackDecisionParams,
} from "./model-fallback-observation.js";
import type { FallbackAttempt, ModelFallbackRouteResolution } from "./model-fallback.types.js";
import type {
FallbackAttempt,
ModelFallbackCandidate,
ModelFallbackRouteResolution,
} from "./model-fallback.types.js";
import type { ModelManifestNormalizationContext } from "./model-ref-shared.js";
import {
resolveSessionSuspensionReason,
@@ -87,6 +91,17 @@ async function loadModelFallbackAuthRuntime() {
return await modelFallbackAuthRuntimeLoader.load();
}
function resolveFallbackAuthScope(params: {
userLockedAuthProfileId?: string;
profileIds?: readonly string[];
}): string | undefined {
if (params.userLockedAuthProfileId) {
return params.userLockedAuthProfileId;
}
// resolveAuthProfileOrder places the profile selected for this model first.
return params.profileIds?.find((id) => id.trim())?.trim();
}
type RunWithModelFallbackParams<T> = {
cfg: OpenClawConfig | undefined;
provider: string;
@@ -95,12 +110,14 @@ type RunWithModelFallbackParams<T> = {
sessionId?: string;
agentId?: string;
sessionKey?: string;
userLockedAuthProfileId?: string;
resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
prepareAgentHarnessRuntime?: (params: {
provider: string;
model: string;
agentHarnessRuntimeOverride?: string;
}) => Promise<void> | void;
prepareCandidateChain?: (candidates: readonly ModelFallbackCandidate[]) => Promise<void> | void;
lane?: string;
agentDir?: string;
/** Optional explicit fallbacks list; when provided (even empty), replaces agents.defaults.model.fallbacks. */
@@ -166,15 +183,19 @@ async function runWithModelFallbackInternal<T>(
requestedRouteResolution: params.requestedRouteResolution,
manifestPlugins: params.manifestPlugins,
});
await params.prepareCandidateChain?.(candidates);
const userLockedAuthProfileId = params.userLockedAuthProfileId?.trim() || undefined;
const authRuntime =
!params.skipAuthProfileRuntime && params.cfg && hasAnyAuthProfileStoreSource(params.agentDir)
? await loadModelFallbackAuthRuntime()
: null;
const authStore = authRuntime
? authRuntime.ensureAuthProfileStore(params.agentDir, {
externalCli: externalCliDiscoveryForProviders({
cfg: params.cfg,
providers: candidates.map((candidate) => candidate.provider),
externalCli: externalCliDiscoveryScoped({
config: params.cfg,
allowKeychainPrompt: false,
providerIds: candidates.map((candidate) => candidate.provider),
...(userLockedAuthProfileId ? { profileIds: [userLockedAuthProfileId] } : {}),
}),
})
: null;
@@ -289,6 +310,37 @@ async function runWithModelFallbackInternal<T>(
...auth,
});
let candidateAuthProfileIds: string[] | undefined;
let userLockedAuthProfileEligible = false;
if (authRuntime && authStore) {
userLockedAuthProfileEligible =
userLockedAuthProfileId !== undefined &&
authRuntime.resolveAuthProfileEligibility({
cfg: params.cfg,
store: authStore,
provider: candidate.provider,
profileId: userLockedAuthProfileId,
}).eligible;
if (!candidateHarnessAuth.skipsProviderAuthCooldown) {
candidateAuthProfileIds = authRuntime.resolveAuthProfileOrder({
cfg: params.cfg,
store: authStore,
provider: candidate.provider,
forModel: candidate.model,
});
authRuntime.maybeReprobeWhamBlockedProfiles({
store: authStore,
profileIds: candidateAuthProfileIds,
agentDir: params.agentDir,
forModel: candidate.model,
});
}
}
const candidateAuthScope = resolveFallbackAuthScope({
userLockedAuthProfileId: userLockedAuthProfileEligible ? userLockedAuthProfileId : undefined,
profileIds: candidateAuthProfileIds,
});
// Skip-known-bad cache: when a previous turn in this session failed this
// candidate with `auth` / `auth_permanent` (e.g. missing or expired
// credentials), suppress repeat attempts for the cache TTL so we do not
@@ -299,12 +351,14 @@ async function runWithModelFallbackInternal<T>(
const skipped = isFallbackCandidateSkipped({
sessionId: params.sessionId,
...candidateRef,
authScope: candidateAuthScope,
});
if (skipped) {
const skipReason =
getFallbackCandidateSkipReason({
sessionId: params.sessionId,
...candidateRef,
authScope: candidateAuthScope,
}) ?? "auth";
const reauthCommand = buildProviderReauthCommand(candidate.provider);
const reauthHint = reauthCommand
@@ -323,23 +377,18 @@ async function runWithModelFallbackInternal<T>(
let runOptions: ModelFallbackRunOptions | undefined;
let attemptedDuringCooldown = false;
let transientProbeProviderForAttempt: string | null = null;
if (authRuntime && authStore && !candidateHarnessAuth.skipsProviderAuthCooldown) {
const profileIds = authRuntime.resolveAuthProfileOrder({
cfg: params.cfg,
store: authStore,
provider: candidate.provider,
});
authRuntime.maybeReprobeWhamBlockedProfiles({
store: authStore,
profileIds,
agentDir: params.agentDir,
forModel: candidate.model,
});
if (
authRuntime &&
authStore &&
candidateAuthProfileIds &&
!candidateHarnessAuth.skipsProviderAuthCooldown
) {
const profileIds = candidateAuthProfileIds;
const isAnyProfileAvailable = profileIds.some(
(id) => !authRuntime.isProfileInCooldown(authStore, id, undefined, candidate.model),
);
if (profileIds.length > 0 && !isAnyProfileAvailable) {
if (profileIds.length > 0 && !isAnyProfileAvailable && !userLockedAuthProfileEligible) {
// All profiles for this provider are in cooldown.
const now = Date.now();
const probeThrottleKey = resolveProbeThrottleKey(candidate.provider, params.agentDir);
@@ -647,6 +696,9 @@ async function runWithModelFallbackInternal<T>(
markFallbackCandidateSkipped({
sessionId: params.sessionId,
...candidateRef,
// The inner runner records the profile that actually failed. Prefer
// that fact because automatic routing can advance before the next turn.
authScope: normalized.profileId?.trim() || candidateAuthScope,
reason: normalized.reason,
});
}
+410 -3
View File
@@ -16,7 +16,7 @@ import { resetLogger, setLoggerOverride } from "../logging/logger.js";
import { createWarnLogCapture } from "../logging/test-helpers/warn-log-capture.js";
import { GatewayDrainingError } from "../process/gateway-work-admission.js";
import { AgentRunTerminalOutcomeError } from "./agent-run-terminal-error.js";
import { AUTH_STORE_VERSION } from "./auth-profiles/constants.js";
import { AUTH_STORE_VERSION, MINIMAX_CLI_PROFILE_ID } from "./auth-profiles/constants.js";
import type { AuthProfileStore } from "./auth-profiles/types.js";
import { testing as cliBackendsTesting } from "./cli-backends.test-support.js";
import { classifyEmbeddedAgentRunResultForModelFallback } from "./embedded-agent-runner/result-fallback-classifier.js";
@@ -113,6 +113,36 @@ const authRuntimeMock = vi.hoisted(() => {
Object.entries(store.profiles)
.filter(([, profile]) => profile.provider === provider)
.map(([id]) => id);
const resolveAuthProfileEligibility = (params: {
store: AuthProfileStore;
provider: string;
profileId: string;
}) => {
const credential = params.store.profiles[params.profileId];
if (!credential) {
return { eligible: false, reasonCode: "profile_missing" as const };
}
if (credential.provider !== params.provider) {
return { eligible: false, reasonCode: "provider_mismatch" as const };
}
if (credential.type === "api_key") {
return credential.key || credential.keyRef
? { eligible: true, reasonCode: "ok" as const }
: { eligible: false, reasonCode: "missing_credential" as const };
}
if (credential.type === "token") {
if (!credential.token && !credential.tokenRef) {
return { eligible: false, reasonCode: "missing_credential" as const };
}
if (credential.expires !== undefined && credential.expires <= now()) {
return { eligible: false, reasonCode: "expired" as const };
}
return { eligible: true, reasonCode: "ok" as const };
}
return credential.access || credential.refresh
? { eligible: true, reasonCode: "ok" as const }
: { eligible: false, reasonCode: "missing_credential" as const };
};
const isProfileInCooldown = (
store: AuthProfileStore,
profileId: string,
@@ -168,10 +198,11 @@ const authRuntimeMock = vi.hoisted(() => {
stores.set(keyFor(agentDir), store);
},
runtime: {
ensureAuthProfileStore: vi.fn((agentDir?: string) => getStore(agentDir)),
ensureAuthProfileStore: vi.fn((agentDir?: string, _options?: unknown) => getStore(agentDir)),
loadAuthProfileStoreForRuntime: vi.fn((agentDir?: string) => getStore(agentDir)),
resolveAuthProfileOrder: (params: { store: AuthProfileStore; provider: string }) =>
getProfileIds(params.store, params.provider),
params.store.order?.[params.provider] ?? getProfileIds(params.store, params.provider),
resolveAuthProfileEligibility,
maybeReprobeWhamBlockedProfiles: vi.fn(),
isProfileInCooldown,
resolveProfilesUnavailableReason: (params: {
@@ -353,6 +384,7 @@ async function runWithStoredAuth(params: {
store: AuthProfileStore;
provider: string;
run: (provider: string, model: string) => Promise<string>;
userLockedAuthProfileId?: string;
}) {
const tempDir = await makeAuthTempDir();
setAuthRuntimeStore(tempDir, params.store);
@@ -362,6 +394,7 @@ async function runWithStoredAuth(params: {
model: "m1",
agentDir: tempDir,
run: params.run,
userLockedAuthProfileId: params.userLockedAuthProfileId,
});
}
@@ -881,6 +914,186 @@ describe("runWithModelFallback", () => {
}
});
it.each([
["provider-owned auth", false],
["harness-owned auth", true],
])(
"scopes auth skip markers to the explicit profile for %s",
async (_label, harnessOwnedAuth) => {
const previous = process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS;
process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS = "60000";
try {
const provider = `scoped-auth-skip-${crypto.randomUUID()}`;
if (harnessOwnedAuth) {
registerFallbackHarness("codex");
}
const profileA = `${provider}:a`;
const profileB = `${provider}:b`;
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/m1",
fallbacks: [`${provider}/m1`, "fallback/ok-model"],
},
},
},
});
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[profileA]: { type: "api_key", provider, key: "key-a" },
[profileB]: { type: "api_key", provider, key: "key-b" },
},
};
const agentDir = await makeAuthTempDir();
setAuthRuntimeStore(agentDir, store);
const run = vi.fn(async (candidateProvider: string, model: string) => {
if (candidateProvider === "openai") {
throw new FailoverError("primary rate limited", {
provider: candidateProvider,
model,
reason: "rate_limit",
});
}
if (candidateProvider === provider) {
throw new FailoverError("explicit profile failed", {
provider: candidateProvider,
model,
reason: "auth",
});
}
return "ok";
});
const execute = (userLockedAuthProfileId: string) =>
runWithModelFallback({
cfg,
provider: "openai",
model: "m1",
sessionId: "session:scoped-auth-skip",
agentDir,
userLockedAuthProfileId,
resolveAgentHarnessRuntimeOverride: (candidateProvider) =>
harnessOwnedAuth && candidateProvider === provider ? "codex" : undefined,
run,
});
await execute(profileA);
await execute(profileB);
const third = await execute(profileB);
expect(third.result).toBe("ok");
expect(run.mock.calls.map(([candidateProvider]) => candidateProvider)).toEqual([
"openai",
provider,
"fallback",
"openai",
provider,
"fallback",
"openai",
"fallback",
]);
expect(third.attempts.find((attempt) => attempt.provider === provider)?.error).toContain(
"recent auth failure",
);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS;
} else {
process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS = previous;
}
}
},
);
it("scopes automatic auth skips to the selected profile", async () => {
const previous = process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS;
process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS = "60000";
try {
const provider = `automatic-auth-skip-${crypto.randomUUID()}`;
const lockedProfile = "openai:locked";
const profileA = `${provider}:a`;
const profileB = `${provider}:b`;
let selectedProfile = profileA;
const cfg = makeCfg({
agents: {
defaults: {
model: {
primary: "openai/m1",
fallbacks: [`${provider}/m1`, "fallback/ok-model"],
},
},
},
});
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[lockedProfile]: { type: "api_key", provider: "openai", key: "key-locked" },
[profileA]: { type: "api_key", provider, key: "key-a" },
[profileB]: { type: "api_key", provider, key: "key-b" },
},
order: { [provider]: [profileA, profileB] },
};
const agentDir = await makeAuthTempDir();
setAuthRuntimeStore(agentDir, store);
const run = vi.fn(async (candidateProvider: string, model: string) => {
if (candidateProvider === "openai") {
throw new FailoverError("primary rate limited", {
provider: candidateProvider,
model,
reason: "rate_limit",
});
}
if (candidateProvider === provider) {
throw new FailoverError("automatic profile failed", {
provider: candidateProvider,
model,
reason: "auth",
profileId: selectedProfile,
});
}
return "ok";
});
const execute = () =>
runWithModelFallback({
cfg,
provider: "openai",
model: "m1",
sessionId: "session:pooled-auth-skip",
agentDir,
userLockedAuthProfileId: lockedProfile,
run,
});
await execute();
selectedProfile = profileB;
store.order = { [provider]: [profileB, profileA] };
await execute();
const third = await execute();
expect(third.result).toBe("ok");
expect(run.mock.calls.map(([candidateProvider]) => candidateProvider)).toEqual([
"openai",
provider,
"fallback",
"openai",
provider,
"fallback",
"openai",
"fallback",
]);
expect(third.attempts.find((attempt) => attempt.provider === provider)?.error).toContain(
"recent auth failure",
);
} finally {
if (previous === undefined) {
delete process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS;
} else {
process.env.OPENCLAW_FALLBACK_SKIP_TTL_MS = previous;
}
}
});
it("skips auth store bootstrap when no auth profile sources exist", async () => {
authSourceCheckMock.hasAnyAuthProfileStoreSource.mockReturnValue(false);
const run = vi.fn().mockResolvedValueOnce("ok");
@@ -3327,6 +3540,200 @@ describe("runWithModelFallback", () => {
});
});
it("attempts an eligible same-provider user lock omitted from cooldown order", async () => {
const provider = `locked-cooldown-${crypto.randomUUID()}`;
const orderedProfileA = `${provider}:a`;
const orderedProfileB = `${provider}:b`;
const orderedProfileIds = [orderedProfileA, orderedProfileB];
const userLockedAuthProfileId = `${provider}:locked`;
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[orderedProfileA]: { type: "api_key", provider, key: "key-a" },
[orderedProfileB]: { type: "api_key", provider, key: "key-b" },
[userLockedAuthProfileId]: { type: "api_key", provider, key: "key-locked" },
"fallback:default": { type: "api_key", provider: "fallback", key: "fallback-key" },
},
order: { [provider]: [...orderedProfileIds] },
usageStats: {
[orderedProfileA]: { cooldownUntil: Date.now() + 60_000 },
[orderedProfileB]: { cooldownUntil: Date.now() + 120_000 },
},
};
const run = vi.fn().mockResolvedValue("ok");
const result = await runWithStoredAuth({
cfg: makeProviderFallbackCfg(provider),
store,
provider,
run,
userLockedAuthProfileId,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([[provider, "m1", { isFinalFallbackAttempt: false }]]);
expect(store.order?.[provider]).toEqual(orderedProfileIds);
});
it("discovers an exact external CLI user lock before cooldown admission", async () => {
const provider = "minimax-portal";
const orderedProfileId = "minimax-portal:api";
const persistedStore: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[orderedProfileId]: { type: "api_key", provider, key: "api-key" },
"fallback:default": { type: "api_key", provider: "fallback", key: "fallback-key" },
},
order: { [provider]: [orderedProfileId] },
usageStats: {
[orderedProfileId]: {
disabledUntil: Date.now() + 60_000,
disabledReason: "auth",
},
},
};
const runtimeStore: AuthProfileStore = {
...persistedStore,
profiles: {
...persistedStore.profiles,
[MINIMAX_CLI_PROFILE_ID]: {
type: "oauth",
provider,
access: "external-access",
refresh: "external-refresh",
expires: Date.now() + 60_000,
},
},
};
authRuntimeMock.runtime.ensureAuthProfileStore.mockReturnValueOnce(runtimeStore);
const run = vi.fn().mockResolvedValue("ok");
const result = await runWithStoredAuth({
cfg: makeProviderFallbackCfg(provider),
store: persistedStore,
provider,
run,
userLockedAuthProfileId: MINIMAX_CLI_PROFILE_ID,
});
expect(result.result).toBe("ok");
expect(persistedStore.profiles[MINIMAX_CLI_PROFILE_ID]).toBeUndefined();
expect(run.mock.calls).toEqual([[provider, "m1", { isFinalFallbackAttempt: false }]]);
const ensureCall = requireMockCall(
authRuntimeMock.runtime.ensureAuthProfileStore,
0,
"ensureAuthProfileStore",
);
expect(requireRecord(ensureCall[1], "auth store options")).toMatchObject({
externalCli: {
mode: "scoped",
allowKeychainPrompt: false,
profileIds: [MINIMAX_CLI_PROFILE_ID],
},
});
});
it("normalizes a blank user lock before cooldown admission", async () => {
const provider = `blank-lock-${crypto.randomUUID()}`;
const orderedProfileId = `${provider}:ordered`;
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[orderedProfileId]: { type: "api_key", provider, key: "ordered-key" },
"": { type: "api_key", provider, key: "blank-key" },
"fallback:default": { type: "api_key", provider: "fallback", key: "fallback-key" },
},
order: { [provider]: [orderedProfileId] },
usageStats: {
[orderedProfileId]: {
disabledUntil: Date.now() + 60_000,
disabledReason: "auth",
},
},
};
const run = createFallbackOnlyRun();
const result = await runWithStoredAuth({
cfg: makeProviderFallbackCfg(provider),
store,
provider,
run,
userLockedAuthProfileId: " ",
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([["fallback", "ok-model", { isFinalFallbackAttempt: true }]]);
const ensureCall = requireMockCall(
authRuntimeMock.runtime.ensureAuthProfileStore,
0,
"ensureAuthProfileStore",
);
expect(requireRecord(ensureCall[1], "auth store options")).toMatchObject({
externalCli: { mode: "scoped" },
});
expect(
requireRecord(
requireRecord(ensureCall[1], "auth store options").externalCli,
"external CLI options",
),
).not.toHaveProperty("profileIds");
});
it.each(["cross-provider", "missing", "ineligible"] as const)(
"does not bypass cooldown order for a %s user lock",
async (kind) => {
const provider = `locked-rejected-${kind}-${crypto.randomUUID()}`;
const orderedProfileA = `${provider}:a`;
const orderedProfileB = `${provider}:b`;
const orderedProfileIds = [orderedProfileA, orderedProfileB];
const userLockedAuthProfileId =
kind === "cross-provider" ? "other:locked" : `${provider}:locked`;
const store: AuthProfileStore = {
version: AUTH_STORE_VERSION,
profiles: {
[orderedProfileA]: { type: "api_key", provider, key: "key-a" },
[orderedProfileB]: { type: "api_key", provider, key: "key-b" },
"fallback:default": { type: "api_key", provider: "fallback", key: "fallback-key" },
},
order: { [provider]: [...orderedProfileIds] },
usageStats: {
[orderedProfileA]: { cooldownUntil: Date.now() + 60_000 },
[orderedProfileB]: { cooldownUntil: Date.now() + 120_000 },
},
};
if (kind === "cross-provider") {
store.profiles[userLockedAuthProfileId] = {
type: "api_key",
provider: "other",
key: "other-key",
};
} else if (kind === "ineligible") {
store.profiles[userLockedAuthProfileId] = {
type: "token",
provider,
token: "expired-token",
expires: Date.now() - 1,
};
}
const run = createFallbackOnlyRun();
const result = await runWithStoredAuth({
cfg: makeProviderFallbackCfg(provider),
store,
provider,
run,
userLockedAuthProfileId,
});
expect(result.result).toBe("ok");
expect(run.mock.calls).toEqual([
[provider, "m1", { allowTransientCooldownProbe: true, isFinalFallbackAttempt: false }],
["fallback", "ok-model", { isFinalFallbackAttempt: true }],
]);
expect(store.order?.[provider]).toEqual(orderedProfileIds);
},
);
it("does not skip OpenRouter when legacy cooldown markers exist", async () => {
const provider = "openrouter";
const cfg = makeProviderFallbackCfg(provider);
@@ -218,11 +218,15 @@ export function guardSessionManager(
suppressAssistantErrorPersistence: opts?.suppressAssistantErrorPersistence,
onMessagePersisted: opts?.onMessagePersisted,
withCompactionPersistence: opts?.withCompactionPersistence,
onUserMessagePersisted: async (message) => {
onUserMessagePersisted: async (message, persistence) => {
const runtimeMessage = runtimeUserMessageByPersistedMessage.get(message);
runtimeUserMessageByPersistedMessage.delete(message);
const recorder = takeRuntimeUserTurnTranscriptRecorder(message);
recorder?.markRuntimePersisted(message);
if (persistence.anchor) {
recorder?.markRuntimePersisted(message, persistence.anchor);
} else {
recorder?.markRuntimePersisted(message);
}
await opts?.onUserMessagePersisted?.(message, runtimeMessage);
},
onUserMessagePersistenceSuppressed: async (message) => {
@@ -754,6 +754,27 @@ describe("installSessionToolResultGuard", () => {
expect(persistedErrors[0]?.stopReason).toBe("error");
});
it("reports the exact persisted user entry id", () => {
const sm = SessionManager.inMemory();
const persisted: Array<{ entryId: string; message: AgentMessage }> = [];
installSessionToolResultGuard(sm, {
onUserMessagePersisted: (message, context) => {
persisted.push({ entryId: context.entryId, message });
},
});
const entryId = sm.appendMessage(
asAppendMessage({ role: "user", content: "exact admission", timestamp: 1 }),
);
expect(persisted).toEqual([
{
entryId,
message: expect.objectContaining({ role: "user", content: "exact admission" }),
},
]);
});
it("models a four-candidate followup fallback cascade producing exactly one user and one assistant-error entry", () => {
const sm = SessionManager.inMemory();
const FALLBACK_CANDIDATES = 4;
+23 -5
View File
@@ -8,6 +8,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { sliceUtf16Safe, truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { publishTranscriptUpdate } from "../config/sessions/session-accessor.js";
import type { TranscriptEntryAnchor } from "../config/sessions/transcript-entry-anchor.js";
import {
boundedJsonUtf8Bytes,
firstEnumerableOwnKeys,
@@ -63,6 +64,14 @@ function resolveMaxToolResultChars(opts?: { maxToolResultChars?: number }): numb
type UserAgentMessage = Extract<AgentMessage, { role: "user" }>;
type AssistantAgentMessage = Extract<AgentMessage, { role: "assistant" }>;
type AsyncMessageCallback<T extends AgentMessage> = (message: T) => void | Promise<void>;
type UserMessagePersistedCallback = (
message: UserAgentMessage,
context: {
anchor?: TranscriptEntryAnchor;
entryId: string;
sessionTarget?: ReturnType<SessionManager["getSessionTarget"]>;
},
) => void | Promise<void>;
type CompactionAppendValidator = (entryId: string, appendedText: string) => boolean;
type AppendMessageOptions = Parameters<SessionManager["appendMessage"]>[1];
@@ -615,7 +624,7 @@ export function installSessionToolResultGuard(
suppressNextUserMessagePersistence?: boolean;
suppressTranscriptOnlyAssistantPersistence?: boolean;
suppressAssistantErrorPersistence?: boolean;
onUserMessagePersisted?: AsyncMessageCallback<UserAgentMessage>;
onUserMessagePersisted?: UserMessagePersistedCallback;
onUserMessagePersistenceSuppressed?: AsyncMessageCallback<UserAgentMessage>;
onUserMessageBlocked?: (message: UserAgentMessage) => void;
onMessagePersisted?: (message: AgentMessage) => void | Promise<void>;
@@ -632,6 +641,8 @@ export function installSessionToolResultGuard(
getPendingIds: () => string[];
} {
const originalAppend = getRawSessionAppendMessage(sessionManager);
const originalAppendWithTranscriptAnchor =
sessionManager.appendMessageWithTranscriptAnchor.bind(sessionManager);
setRawSessionAppendMessage(sessionManager, originalAppend);
const pendingState = createPendingToolCallState();
const persistMessage = (message: AgentMessage) => {
@@ -660,23 +671,25 @@ export function installSessionToolResultGuard(
message: AgentMessage,
options?: AppendMessageOptions,
): {
anchor?: TranscriptEntryAnchor;
entryId: string;
messageSeq?: number;
sessionTarget?: ReturnType<SessionManager["getSessionTarget"]>;
} => {
const parentEntryId = sessionManager.getLeafId();
const appendParentEntryId = sessionManager.getAppendParentId();
const entryId = originalAppend(message as never, options);
const { entryId, anchor } = originalAppendWithTranscriptAnchor(message as never, options);
if (sessionManager.getAppendParentId() === appendParentEntryId) {
return { entryId };
return { entryId, ...(anchor ? { anchor } : {}) };
}
void opts?.onMessagePersisted?.(message);
const sessionTarget = sessionManager.getSessionTarget();
if (!sessionTarget) {
return { entryId };
return { entryId, ...(anchor ? { anchor } : {}) };
}
return {
entryId,
...(anchor ? { anchor } : {}),
sessionTarget,
messageSeq: resolveAppendedMessageSeq({
sessionManager,
@@ -887,6 +900,7 @@ export function installSessionToolResultGuard(
return undefined;
}
const {
anchor,
entryId: result,
messageSeq,
sessionTarget,
@@ -906,7 +920,11 @@ export function installSessionToolResultGuard(
pendingState.trackToolCalls(toolCalls);
}
if (isUserAgentMessage(finalMessage)) {
void opts?.onUserMessagePersisted?.(finalMessage);
void opts?.onUserMessagePersisted?.(finalMessage, {
...(anchor ? { anchor } : {}),
entryId: result,
...(sessionTarget ? { sessionTarget } : {}),
});
}
if (
finalRole === "assistant" &&
+41 -15
View File
@@ -1,3 +1,4 @@
import type { TranscriptEntryAnchor } from "../../config/sessions/session-accessor.js";
import { isSessionTranscriptSideAppendEntry } from "../../config/sessions/transcript-tree.js";
import type { ImageContent, Message, TextContent } from "../../llm/types.js";
import {
@@ -29,7 +30,10 @@ import type {
} from "./session-manager-types.js";
export class SessionManagerEntries extends SessionManagerPersistence {
protected appendEntry(entry: SessionEntry, options?: AppendPersistenceOptions): void {
protected appendEntry(
entry: SessionEntry,
options?: AppendPersistenceOptions,
): TranscriptEntryAnchor | undefined {
// oxlint-disable-next-line unicorn/prefer-structured-clone -- Match the persisted JSON/toJSON shape exactly.
const canonicalEntry = JSON.parse(JSON.stringify(entry)) as SessionEntry;
if (!isIndexedSessionEntry(canonicalEntry)) {
@@ -44,22 +48,31 @@ export class SessionManagerEntries extends SessionManagerPersistence {
...(activeBranchAppend ? { appendIntent: "active-branch" } : {}),
});
if (persistenceResult && typeof persistenceResult === "object") {
this.reloadPersistedTranscript();
const adoptedMessageId =
canonicalEntry.type === "message"
? this.resolveCurrentKeyedUserId(canonicalEntry.message)
: undefined;
if (adoptedMessageId !== persistenceResult.adoptedMessageId) {
throw new Error(`Session transcript parent entry was not persisted: ${canonicalEntry.id}`);
if (persistenceResult.adoptedMessageId) {
this.reloadPersistedTranscript();
const adoptedMessageId =
canonicalEntry.type === "message"
? this.resolveCurrentKeyedUserId(canonicalEntry.message)
: undefined;
if (adoptedMessageId !== persistenceResult.adoptedMessageId) {
throw new Error(
`Session transcript parent entry was not persisted: ${canonicalEntry.id}`,
);
}
this.pendingDeliberateAppend = false;
return persistenceResult.anchor;
}
this.pendingDeliberateAppend = false;
return;
}
const effectiveParentId = persistenceResult;
const effectiveParentId =
persistenceResult && typeof persistenceResult === "object"
? persistenceResult.effectiveParentId
: persistenceResult;
if (effectiveParentId !== undefined && effectiveParentId !== canonicalEntry.parentId) {
this.reloadPersistedTranscript();
this.pendingDeliberateAppend = false;
return;
return persistenceResult && typeof persistenceResult === "object"
? persistenceResult.anchor
: undefined;
}
if (
!isSessionTranscriptSideAppendEntry(canonicalEntry) &&
@@ -80,6 +93,9 @@ export class SessionManagerEntries extends SessionManagerPersistence {
this.appendMode = undefined;
this.promptReleasedSideBranchParentId = undefined;
}
return persistenceResult && typeof persistenceResult === "object"
? persistenceResult.anchor
: undefined;
}
private resolveCurrentKeyedUserId(message: SessionMessageEntry["message"]): string | undefined {
@@ -111,12 +127,19 @@ export class SessionManagerEntries extends SessionManagerPersistence {
message: Message | CustomMessage | BashExecutionMessage,
options?: AppendPersistenceOptions,
): string {
return this.appendMessageWithTranscriptAnchor(message, options).entryId;
}
appendMessageWithTranscriptAnchor(
message: Message | CustomMessage | BashExecutionMessage,
options?: AppendPersistenceOptions,
): { entryId: string; anchor?: TranscriptEntryAnchor } {
if (options?.idempotencyLookup !== "caller-checked") {
const currentUserId = this.resolveCurrentKeyedUserId(message);
if (currentUserId) {
// Session setup may insert context-free metadata after the ingress-persisted user.
// Keep that metadata as the append parent while adopting the canonical user once.
return currentUserId;
return { entryId: currentUserId };
}
}
const entry: SessionMessageEntry = {
@@ -126,8 +149,11 @@ export class SessionManagerEntries extends SessionManagerPersistence {
timestamp: new Date().toISOString(),
message,
};
this.appendEntry(entry, options);
return this.resolveCurrentKeyedUserId(message) ?? entry.id;
const anchor = this.appendEntry(entry, options);
return {
entryId: this.resolveCurrentKeyedUserId(message) ?? entry.id,
...(anchor ? { anchor } : {}),
};
}
appendThinkingLevelChange(thinkingLevel: string): string {
@@ -2,6 +2,7 @@ import {
appendTranscriptEventSync,
appendTranscriptMessageSync,
ensureSessionEntrySync,
type TranscriptEntryAnchor,
} from "../../config/sessions/session-accessor.js";
import { isSessionTranscriptSideAppendEntry } from "../../config/sessions/transcript-tree.js";
import {
@@ -18,7 +19,15 @@ import type {
SessionEntry,
} from "./session-manager-types.js";
type PersistRecordResult = string | null | undefined | { adoptedMessageId: string };
type PersistRecordResult =
| string
| null
| undefined
| {
anchor?: TranscriptEntryAnchor;
adoptedMessageId?: string;
effectiveParentId: string | null;
};
function requireTranscriptEventAppend(
result: ReturnType<typeof appendTranscriptEventSync>,
@@ -229,7 +238,14 @@ export class SessionManagerPersistence extends SessionManagerCore {
if (idempotencyKey && options?.idempotencyLookup !== "caller-checked") {
// Ingress can commit the keyed user after this manager loaded. The
// caller reloads and adopts only when that canonical row is still active.
return { adoptedMessageId: result.messageId };
if (!result.anchor) {
throw new Error(`Session transcript anchor was not returned: ${result.messageId}`);
}
return {
adoptedMessageId: result.messageId,
anchor: result.anchor,
effectiveParentId: result.effectiveParentId ?? null,
};
}
throw new Error(`Session transcript parent entry was not persisted: ${entry.id}`);
}
@@ -242,7 +258,10 @@ export class SessionManagerPersistence extends SessionManagerCore {
if (result.effectiveParentId === undefined) {
throw new Error(`Session transcript append parent was not returned: ${entry.id}`);
}
return result.effectiveParentId;
return {
...(result.anchor ? { anchor: result.anchor } : {}),
effectiveParentId: result.effectiveParentId,
};
}
mergePromptReleasedSessionEntries(
+7
View File
@@ -67,6 +67,13 @@ export class SessionManager extends SessionManagerBranching {
return super.appendMessage(message, options);
}
override appendMessageWithTranscriptAnchor(
message: Message | CustomMessage | BashExecutionMessage,
options?: AppendPersistenceOptions,
) {
return super.appendMessageWithTranscriptAnchor(message, options);
}
static open(target: SessionTranscriptRuntimeTarget, cwdOverride?: string): SessionManager {
const entries = loadTranscriptEventsSync(target) as FileEntry[];
const header = entries.find(
+19 -5
View File
@@ -76,7 +76,9 @@ describe("persistStickyModelSelection", () => {
])("writes the $name", async ({ agentId, cfg, target }) => {
mocks.cfg = structuredClone(cfg);
persistStickyModelSelectionBestEffort({ agentId, model: " openai/gpt-5.6-sol " });
expect(persistStickyModelSelectionBestEffort({ agentId, model: " openai/gpt-5.6-sol " })).toBe(
"requested",
);
await vi.waitFor(() =>
expect(mocks.info).toHaveBeenCalledWith(
`persisted sticky model selection agentId=${agentId} model=openai/gpt-5.6-sol target=${target}`,
@@ -94,7 +96,9 @@ describe("persistStickyModelSelection", () => {
});
it("rejects an empty model before starting a config mutation", async () => {
persistStickyModelSelectionBestEffort({ agentId: "main", model: " " });
expect(persistStickyModelSelectionBestEffort({ agentId: "main", model: " " })).toBe(
"requested",
);
await vi.waitFor(() =>
expect(mocks.warn).toHaveBeenCalledWith(
@@ -109,7 +113,7 @@ describe("persistStickyModelSelection", () => {
expect(
persistStickyModelSelectionBestEffort({ agentId: "main", model: "openai/gpt-5.6-sol" }),
).toBeUndefined();
).toBe("requested");
await vi.waitFor(() =>
expect(mocks.warn).toHaveBeenCalledWith(
@@ -121,8 +125,18 @@ describe("persistStickyModelSelection", () => {
it("skips immutable Nix config and warns only once per process", () => {
mocks.isNixMode = true;
persistStickyModelSelectionBestEffort({ agentId: "main", model: "openai/gpt-5.6-sol" });
persistStickyModelSelectionBestEffort({ agentId: "work", model: "openai/gpt-5.6-luna" });
expect(
persistStickyModelSelectionBestEffort({
agentId: "main",
model: "openai/gpt-5.6-sol",
}),
).toBe("skipped-immutable");
expect(
persistStickyModelSelectionBestEffort({
agentId: "work",
model: "openai/gpt-5.6-luna",
}),
).toBe("skipped-immutable");
expect(mocks.mutateConfigFileWithRetry).not.toHaveBeenCalled();
expect(mocks.warn).toHaveBeenCalledOnce();
+5 -2
View File
@@ -9,6 +9,8 @@ import { setAgentEffectiveModelPrimary, type AgentModelPrimaryWriteTarget } from
const log = createSubsystemLogger("agents/sticky-model-selection");
let warnedImmutableConfig = false;
export type StickyModelSelectionDispatchOutcome = "requested" | "skipped-immutable";
/** Persists a validated session model selection at the agent's effective config layer. */
async function persistStickyModelSelection(params: {
agentId: string;
@@ -36,7 +38,7 @@ async function persistStickyModelSelection(params: {
export function persistStickyModelSelectionBestEffort(params: {
agentId: string;
model: string;
}): void {
}): StickyModelSelectionDispatchOutcome {
if (resolveIsNixMode()) {
// A Nix-managed gateway can switch models but can never persist this preference.
// Warn once per process so repeated switches do not flood the operator log.
@@ -46,11 +48,12 @@ export function persistStickyModelSelectionBestEffort(params: {
`skipped sticky model persistence agentId=${params.agentId} model=${params.model} reason=config is immutable in OPENCLAW_NIX_MODE`,
);
}
return;
return "skipped-immutable";
}
void persistStickyModelSelection(params).catch((error: unknown) => {
log.warn(
`failed sticky model persistence agentId=${params.agentId} model=${params.model} reason=${formatErrorMessage(error)}`,
);
});
return "requested";
}
@@ -120,6 +120,23 @@ export function installEmbeddedRunnerBaseE2eMocks(options?: {
dispose: async () => undefined,
})),
resolveContextEngineOwnerPluginId: vi.fn(() => undefined),
resolveLogicalTurnContextEngines: vi.fn(async () => {
const engine = {
info: { id: "legacy", name: "Legacy Context Engine" },
async ingest() {
return { ingested: false };
},
async assemble({ messages }: { messages: unknown[] }) {
return { messages, estimatedTokens: 0 };
},
async compact() {
return { ok: true, compacted: false };
},
async dispose() {},
};
const ref = { engine, registeredId: "legacy" };
return { configured: ref, configuredId: "legacy", fallback: ref };
}),
}));
vi.doMock("../runtime-plugins.js", () => ({
loadAgentRuntimePluginRegistryHandle: vi.fn(() => createEmptyPluginRegistry()),
+9 -3
View File
@@ -636,9 +636,15 @@ export function buildBuiltinChatCommands(
argsParsing: "none",
formatArgs: COMMAND_ARG_FORMATTERS.exec,
}),
defineBuiltinCommand("model", "Show or set the model.", "options", "essential", {
args: [defineCommandArgument("model", "Model id (provider/model or id)")],
}),
defineBuiltinCommand(
"model",
"Show or set the model; direct owner/admin selections request a default update.",
"options",
"essential",
{
args: [defineCommandArgument("model", "Model id; add -s to change only this session")],
},
),
defineBuiltinCommand("models", "List model providers/models.", "options", "standard", {
acceptsArgs: true,
}),

Some files were not shown because too many files have changed in this diff Show More