diff --git a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
index 6f375827173e..bd35cdb0a5fd 100644
--- a/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
+++ b/.github/workflows/openclaw-live-and-e2e-checks-reusable.yml
@@ -439,6 +439,8 @@ jobs:
add_profile_suite native-live-src-gateway-profiles-google "stable full"
add_profile_suite native-live-src-gateway-profiles-minimax "stable full"
add_profile_suite native-live-src-gateway-profiles-openai "beta minimum stable full"
+ add_profile_suite native-live-src-gateway-profiles-openai-api-default "stable full"
+ add_profile_suite native-live-src-gateway-profiles-openai-gpt56-ultra "stable full"
add_profile_suite native-live-src-gateway-profiles-fireworks "full"
add_profile_suite native-live-src-gateway-profiles-deepseek "full"
add_profile_suite native-live-src-gateway-profiles-opencode-go "full"
@@ -467,6 +469,10 @@ jobs:
add_profile_suite live-cli-backend-docker "stable full"
add_profile_suite live-acp-bind-docker "stable full"
add_profile_suite live-codex-harness-docker "stable full"
+ add_profile_suite live-codex-harness-gpt56-sol-docker "stable full"
+ add_profile_suite live-codex-harness-gpt56-terra-docker "stable full"
+ add_profile_suite live-codex-harness-gpt56-luna-docker "stable full"
+ add_profile_suite live-codex-harness-gpt56-docker "stable full"
add_profile_suite live-subagent-announce-docker "stable full"
add_profile_suite native-live-extensions-a-k "full"
@@ -2100,6 +2106,18 @@ jobs:
timeout_minutes: 60
profile_env_only: false
profiles: beta minimum stable full
+ - suite_id: native-live-src-gateway-profiles-openai-api-default
+ label: Native live gateway fresh OpenAI API-key default
+ command: OPENCLAW_LIVE_GATEWAY_OPENAI_API_DEFAULT=1 OPENCLAW_LIVE_GATEWAY_THINKING=off OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MAX_MODELS=1 OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=180000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=600000 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
+ timeout_minutes: 60
+ profile_env_only: false
+ profiles: stable full
+ - suite_id: native-live-src-gateway-profiles-openai-gpt56-ultra
+ label: Native live gateway OpenAI GPT-5.6 Ultra
+ command: OPENCLAW_LIVE_GATEWAY_THINKING=ultra OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai OPENCLAW_LIVE_GATEWAY_MODELS=openai/gpt-5.6-sol,openai/gpt-5.6-terra,openai/gpt-5.6-luna OPENCLAW_LIVE_GATEWAY_STEP_TIMEOUT_MS=300000 OPENCLAW_LIVE_GATEWAY_MODEL_TIMEOUT_MS=900000 node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
+ timeout_minutes: 75
+ profile_env_only: false
+ profiles: stable full
- suite_id: native-live-src-gateway-profiles-fireworks
label: Native live gateway profiles Fireworks
command: OPENCLAW_LIVE_GATEWAY_PROVIDERS=fireworks node .release-harness/scripts/test-live-shard.mjs native-live-src-gateway-profiles
@@ -2322,7 +2340,7 @@ jobs:
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
;;
- live-codex-harness-docker)
+ live-codex-harness*-docker)
# Keep CI on the API-key path for now. The staged Codex auth secret
# is currently stale, but the wrapper still supports codex-auth for
# local maintainer reruns without changing Peter's flow.
@@ -2454,6 +2472,27 @@ jobs:
timeout_minutes: 40
profile_env_only: false
profiles: stable full
+ - suite_id: live-codex-harness-gpt56-sol-docker
+ suite_group: live-codex-harness-gpt56-docker
+ label: Docker live Codex GPT-5.6 Sol Ultra
+ command: OPENCLAW_LIVE_CODEX_HARNESS_TARGETS=openai/gpt-5.6-sol=ultra OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
+ timeout_minutes: 40
+ profile_env_only: false
+ profiles: stable full
+ - suite_id: live-codex-harness-gpt56-terra-docker
+ suite_group: live-codex-harness-gpt56-docker
+ label: Docker live Codex GPT-5.6 Terra Ultra
+ command: OPENCLAW_LIVE_CODEX_HARNESS_TARGETS=openai/gpt-5.6-terra=ultra OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
+ timeout_minutes: 40
+ profile_env_only: false
+ profiles: stable full
+ - suite_id: live-codex-harness-gpt56-luna-docker
+ suite_group: live-codex-harness-gpt56-docker
+ label: Docker live Codex GPT-5.6 Luna Max
+ command: OPENCLAW_LIVE_CODEX_HARNESS_TARGETS=openai/gpt-5.6-luna=max OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh
+ timeout_minutes: 40
+ profile_env_only: false
+ profiles: stable full
- suite_id: live-subagent-announce-docker
label: Docker live subagent announce
command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh
@@ -2565,7 +2604,7 @@ jobs:
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
echo "OPENCLAW_TEST_CONSOLE=1" >> "$GITHUB_ENV"
;;
- live-codex-harness-docker)
+ live-codex-harness*-docker)
echo "OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key" >> "$GITHUB_ENV"
echo "OPENCLAW_LIVE_CODEX_HARNESS_DEBUG=1" >> "$GITHUB_ENV"
echo "OPENCLAW_CLI_BACKEND_LOG_OUTPUT=1" >> "$GITHUB_ENV"
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d4f73f2fb52d..b1a40d58ff80 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -6,6 +6,7 @@ Docs: https://docs.openclaw.ai
### Changes
+- **GPT-5.6 Ultra and runtime switching:** support Sol, Terra, and Luna across OpenClaw and Codex engines; keep model, runtime, and thinking selection atomic through `/model` and fallback; and add live matrix coverage for both harnesses. (#98021) Thanks @anyech.
- **OpenAI GPT-5.6 defaults:** use `openai/gpt-5.6` (Sol alias) for fresh API-key setup and exact `openai/gpt-5.6-sol` for fresh Codex/OAuth setup, while preserving existing primaries, fallbacks, aliases, and explicit GPT-5.5 selections. (#103234)
- **Meta provider:** add bundled `muse-spark-1.1` model support with Responses API streaming, tool calls, encrypted reasoning replay, onboarding, and standalone npm/ClawHub distribution. (#102873) Thanks @HamidShojanazeri.
- **Android chat agent selector:** switch the active agent directly from the live chat screen while keeping chat, Talk mode, and home canvas on the same canonical session. (#80422) Thanks @bcperry.
diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index daa3fcbd4cf6..1e4587f7f188 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -151,7 +151,7 @@ Every job carries exactly one payload kind, chosen by flag:
On `cron edit`, removes the per-job model override so the job follows normal cron model precedence (stored cron-session override, else agent/default model). Cannot combine with `--model`.
- Thinking level override (`off|minimal|low|medium|high|xhigh|adaptive|max`).
+ Thinking level override (`off|minimal|low|medium|high|xhigh|adaptive|max|ultra`). Available levels still depend on the selected model and agent runtime.
On `cron edit`, removes the per-job thinking override. Cannot combine with `--thinking`.
diff --git a/docs/help/testing-live.md b/docs/help/testing-live.md
index a35222aceacb..0798098c18a2 100644
--- a/docs/help/testing-live.md
+++ b/docs/help/testing-live.md
@@ -288,8 +288,8 @@ Docker notes:
- Goal: validate the plugin-owned Codex harness through the normal gateway
`agent` method:
- load the bundled `codex` plugin
- - select `openai/gpt-5.5`, which routes OpenAI agent turns through Codex by default
- - send a first gateway agent turn to `openai/gpt-5.5` with the Codex harness selected
+ - select an OpenAI model through `/model [ --runtime codex`
+ - send a first gateway agent turn with the requested thinking level
- send a second turn to the same OpenClaw session and verify the app-server
thread can resume
- run `/codex status` and `/codex models` through the same gateway command
@@ -299,15 +299,22 @@ Docker notes:
denied so the agent asks back
- Test: `src/gateway/gateway-codex-harness.live.test.ts`
- Enable: `OPENCLAW_LIVE_CODEX_HARNESS=1`
-- Default model: `openai/gpt-5.5`
+- Harness baseline model: `codex/gpt-5.5`
+- Fresh OpenAI API-key selection default: `openai/gpt-5.6`
+- Default thinking: `low`
+- Model override: `OPENCLAW_LIVE_CODEX_HARNESS_MODEL=openai/`
+- Thinking override: `OPENCLAW_LIVE_CODEX_HARNESS_THINKING=`
+- Matrix override: `OPENCLAW_LIVE_CODEX_HARNESS_TARGETS==,...`
+- Auth mode: `OPENCLAW_LIVE_CODEX_HARNESS_AUTH=codex-auth` (default) uses the
+ copied Codex login; `api-key` uses `OPENAI_API_KEY` through Codex app-server.
- Optional image probe: `OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE=1`
- Optional MCP/tool probe: `OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE=1`
- Optional Guardian probe: `OPENCLAW_LIVE_CODEX_HARNESS_GUARDIAN_PROBE=1`
- The smoke forces provider/model `agentRuntime.id: "codex"` so a broken Codex
harness cannot pass by silently falling back to OpenClaw.
-- Auth: Codex app-server auth from the local Codex subscription login. Docker
- smokes can also provide `OPENAI_API_KEY` for non-Codex probes when applicable,
- plus optional copied `~/.codex/auth.json` and `~/.codex/config.toml`.
+- Auth: Codex app-server auth from the local Codex subscription login, or
+ `OPENAI_API_KEY` when `OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key`. Docker can
+ copy `~/.codex/auth.json` and `~/.codex/config.toml` for subscription runs.
Local recipe:
@@ -326,6 +333,36 @@ Docker recipe:
pnpm test:docker:live-codex-harness
```
+GPT-5.6 native Codex matrix:
+
+```bash
+OPENCLAW_LIVE_CODEX_HARNESS_AUTH=api-key \
+ OPENCLAW_LIVE_CODEX_HARNESS_TARGETS='openai/gpt-5.6-sol=ultra,openai/gpt-5.6-terra=ultra,openai/gpt-5.6-luna=max' \
+ pnpm test:docker:live-codex-harness
+```
+
+Fresh OpenAI API-key default:
+
+```bash
+OPENCLAW_LIVE_GATEWAY_OPENAI_API_DEFAULT=1 \
+ OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai \
+ OPENCLAW_LIVE_GATEWAY_THINKING=off \
+ pnpm test:live -- src/gateway/gateway-models.profiles.live.test.ts
+```
+
+This proof leaves `OPENCLAW_LIVE_GATEWAY_MODELS` unset, resolves the model through
+the fresh onboarding inference-selection seam, asserts `openai/gpt-5.6`, and then
+runs a real gateway turn with that resolved model.
+
+GPT-5.6 embedded OpenClaw matrix:
+
+```bash
+OPENCLAW_LIVE_GATEWAY_THINKING=ultra \
+ OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai \
+ OPENCLAW_LIVE_GATEWAY_MODELS='openai/gpt-5.6-sol,openai/gpt-5.6-terra,openai/gpt-5.6-luna' \
+ pnpm test:live -- src/gateway/gateway-models.profiles.live.test.ts
+```
+
Docker notes:
- The Docker runner lives at `scripts/test-live-codex-harness-docker.sh`.
@@ -339,6 +376,9 @@ Docker notes:
run.
- Docker uses the same explicit Codex runtime config, so legacy aliases or OpenClaw
fallback cannot hide a Codex harness regression.
+- Matrix targets run sequentially in one container. The Docker script scales its
+ default 35-minute timeout by target count; any outer shell or CI timeout must
+ allow the same total. Canonical CI keeps each GPT-5.6 target in a separate shard.
### Recommended live recipes
diff --git a/docs/tools/thinking.md b/docs/tools/thinking.md
index 133a3cc0468f..c40a6fe9ec22 100644
--- a/docs/tools/thinking.md
+++ b/docs/tools/thinking.md
@@ -8,7 +8,7 @@ title: "Thinking levels"
## What it does
- Inline directive in any inbound body: `/t `, `/think:`, or `/thinking `.
-- Levels (aliases): `off | minimal | low | medium | high | xhigh | adaptive | max`, roughly mirroring Anthropic's classic "think" < "think hard" < "think harder" < "ultrathink" magic-word ladder:
+- Levels (aliases): `off | minimal | low | medium | high | xhigh | adaptive | max | ultra`, roughly mirroring Anthropic's classic "think" < "think hard" < "think harder" < "ultrathink" magic-word ladder:
- minimal ~ "think"
- low ~ "think hard"
- medium ~ "think harder"
@@ -16,11 +16,12 @@ title: "Thinking levels"
- xhigh ~ "ultrathink+" (GPT-5.2+ and Codex models, plus Anthropic Claude Opus 4.7+ effort)
- adaptive → provider-managed adaptive thinking (supported for Claude 4.6 on Anthropic/Bedrock, Anthropic Claude Opus 4.7+, and Google Gemini dynamic thinking)
- max → provider max reasoning (Anthropic Claude Opus 4.7+; Ollama maps this to its highest native `think` effort)
+ - ultra → provider max reasoning plus proactive sub-agent orchestration when the selected model/runtime supports it
- `x-high`, `x_high`, `extra-high`, `extra high`, and `extra_high` map to `xhigh`.
- `highest` maps to `high`.
- Provider notes:
- Thinking menus and pickers are provider-profile driven. Provider plugins declare the exact level set for the selected model, including labels such as binary `on`.
- - `adaptive`, `xhigh`, and `max` are only advertised for provider/model profiles that support them. Typed directives for unsupported levels are rejected with that model's valid options.
+ - `adaptive`, `xhigh`, `max`, and `ultra` are only advertised for provider/model/runtime profiles that support them. Typed directives for unsupported levels are rejected with that model's valid options.
- Existing stored unsupported levels are remapped by provider profile rank. `adaptive` falls back to `medium` on non-adaptive models, while `xhigh` and `max` fall back to the largest supported non-off level for the selected model.
- Anthropic Claude 4.6 models default to `adaptive` when no explicit thinking level is set.
- Anthropic Claude Opus 4.8 and Opus 4.7 keep thinking off unless you explicitly set a thinking level. Opus 4.8's provider-owned effort default is `high` after adaptive thinking is enabled.
@@ -30,6 +31,8 @@ title: "Thinking levels"
- OpenRouter-routed DeepSeek V4 models expose `/think xhigh` and send OpenRouter-supported `reasoning.effort` values instead of DeepSeek-native top-level `reasoning_effort`. Lower non-off levels map to `high`, and stored `max` overrides fall back to `xhigh`.
- Ollama thinking-capable models expose `/think low|medium|high|max`; `max` maps to native `think: "high"` because Ollama's native API accepts `low`, `medium`, and `high` effort strings.
- OpenAI GPT models map `/think` through model-specific Responses API effort support. `/think off` sends `reasoning.effort: "none"` only when the target model supports it; otherwise OpenClaw omits the disabled reasoning payload instead of sending an unsupported value.
+ - GPT-5.6 Sol and Terra expose native `/think ultra` through the Codex runtime. GPT-5.6 Luna exposes levels through `max` because its Codex catalog does not advertise Ultra.
+ - The embedded OpenClaw runtime exposes logical `/think ultra` for GPT-5.6 Sol, Terra, and Luna. It sends provider max effort and adds run-scoped proactive sub-agent orchestration guidance.
- Custom OpenAI-compatible catalog entries can opt into `/think xhigh` by setting `models.providers..models[].compat.supportedReasoningEfforts` to include `"xhigh"`. This uses the same compat metadata that maps outbound OpenAI reasoning effort payloads, so menus, session validation, agent CLI, and `llm-task` agree with transport behavior.
- Stale configured OpenRouter Hunter Alpha refs skip proxy reasoning injection because that retired route could return final answer text through reasoning fields.
- Google Gemini maps `/think adaptive` to Gemini's provider-owned dynamic thinking. Gemini 3 requests omit a fixed `thinkingLevel`, while Gemini 2.5 requests send `thinkingBudget: -1`; fixed levels still map to the closest Gemini `thinkingLevel` or budget for that model family.
@@ -136,9 +139,9 @@ Malformed local-model reasoning tags are handled conservatively. Closed `
- Provider plugins can expose `resolveThinkingProfile(ctx)` to define the model's supported levels and default.
- Provider plugins that proxy Claude models should reuse `resolveClaudeThinkingProfile(modelId)` from `openclaw/plugin-sdk/provider-model-shared` so direct Anthropic and proxy catalogs stay aligned.
-- Each profile level has a stored canonical `id` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `adaptive`, or `max`) and may include a display `label`. Binary providers use `{ id: "low", label: "on" }`.
+- Each profile level has a stored canonical `id` (`off`, `minimal`, `low`, `medium`, `high`, `xhigh`, `adaptive`, `max`, or `ultra`) and may include a display `label`. Binary providers use `{ id: "low", label: "on" }`.
- Profile hooks receive merged catalog facts when available, including `reasoning`, `compat.thinkingFormat`, and `compat.supportedReasoningEfforts`. Use those facts to expose binary or custom profiles only when the configured request contract supports the matching payload.
-- Tool plugins that need to validate an explicit thinking override should use `api.runtime.agent.resolveThinkingPolicy({ provider, model })` plus `api.runtime.agent.normalizeThinkingLevel(...)`; they should not keep their own provider/model level lists.
+- Tool plugins that need to validate an explicit thinking override should use `api.runtime.agent.resolveThinkingPolicy({ provider, model, agentRuntime })` plus `api.runtime.agent.normalizeThinkingLevel(...)`; they should not keep their own provider/model level lists. Pass `agentRuntime` when the tool owns the execution path, such as an always-embedded run.
- Tool plugins with access to configured custom model metadata can pass `catalog` into `resolveThinkingPolicy` so `compat.supportedReasoningEfforts` opt-ins are reflected in plugin-side validation.
- Published legacy hooks (`supportsXHighThinking`, `isBinaryThinking`, and `resolveDefaultThinkingLevel`) remain as compatibility adapters, but new custom level sets should use `resolveThinkingProfile`.
- Gateway rows/defaults expose `thinkingLevels`, `thinkingOptions`, and `thinkingDefault` so ACP/chat clients render the same profile ids and labels that runtime validation uses.
diff --git a/extensions/codex/provider.test.ts b/extensions/codex/provider.test.ts
index e1324c627e30..64da240fe3a0 100644
--- a/extensions/codex/provider.test.ts
+++ b/extensions/codex/provider.test.ts
@@ -2,7 +2,11 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { CODEX_GPT5_BEHAVIOR_CONTRACT } from "./prompt-overlay.js";
import { codexProviderDiscovery } from "./provider-discovery.js";
-import { buildCodexProvider, buildCodexProviderCatalog } from "./provider.js";
+import {
+ buildCodexProvider,
+ buildCodexProviderCatalog,
+ resolveCodexSupportedReasoningEffort,
+} from "./provider.js";
import { CodexAppServerClient } from "./src/app-server/client.js";
import type { listCodexAppServerModels } from "./src/app-server/models.js";
import {
@@ -393,17 +397,18 @@ describe("codex provider", () => {
).toContain("max");
});
- it("exposes max for the GPT-5.6 series", () => {
+ it("exposes max only for known native GPT-5.6 models", () => {
const provider = buildCodexProvider();
const levels = (modelId: string) =>
provider
.resolveThinkingProfile?.({ provider: "codex", modelId } as never)
?.levels.map((level) => level.id);
- expect(levels("gpt-5.6")).toContain("max");
- expect(levels("gpt-5.6-sol-oai")).toContain("max");
+ expect(levels("gpt-5.6-sol")).toContain("max");
expect(levels("gpt-5.6-terra")).toContain("max");
expect(levels("gpt-5.6-luna")).toContain("max");
+ expect(levels("gpt-5.6")).not.toContain("max");
+ expect(levels("gpt-5.6-sol-oai")).not.toContain("max");
});
it("uses app-server reasoning metadata as the authoritative thinking profile", () => {
@@ -420,6 +425,83 @@ describe("codex provider", () => {
).toEqual(["off", "medium", "high", "xhigh"]);
});
+ it("uses known GPT-5.6 native Codex fallbacks when model/list metadata is unavailable", () => {
+ const provider = buildCodexProvider();
+ const levels = (modelId: string, supportedReasoningEfforts?: string[]) =>
+ provider
+ .resolveThinkingProfile?.({
+ provider: "codex",
+ modelId,
+ ...(supportedReasoningEfforts ? { compat: { supportedReasoningEfforts } } : {}),
+ } as never)
+ ?.levels.map((level) => level.id);
+
+ expect(levels("gpt-5.6-sol")).toContain("ultra");
+ expect(levels("gpt-5.6-terra")).toContain("ultra");
+ expect(levels("gpt-5.6-luna")).toEqual(["off", "low", "medium", "high", "xhigh", "max"]);
+ expect(levels("gpt-5.6")).not.toContain("ultra");
+
+ const directOpenAIEfforts = ["none", "low", "medium", "high", "xhigh", "max"];
+ expect(levels("gpt-5.6-sol", directOpenAIEfforts)).toContain("ultra");
+ expect(levels("gpt-5.6-terra", directOpenAIEfforts)).toContain("ultra");
+ });
+
+ it.each([
+ { modelId: "gpt-5.6-sol", expected: "low" },
+ { modelId: "gpt-5.6-terra", expected: "medium" },
+ { modelId: "gpt-5.6-luna", expected: "medium" },
+ ] as const)("uses the native $modelId default reasoning effort", ({ modelId, expected }) => {
+ const provider = buildCodexProvider();
+
+ expect(
+ provider.resolveThinkingProfile?.({ provider: "codex", modelId } as never)?.defaultLevel,
+ ).toBe(expected);
+ });
+
+ it("omits the native default when authoritative model/list metadata does not support it", () => {
+ const provider = buildCodexProvider();
+
+ expect(
+ provider.resolveThinkingProfile?.({
+ provider: "codex",
+ modelId: "gpt-5.6-sol",
+ compat: { supportedReasoningEfforts: ["high"] },
+ } as never)?.defaultLevel,
+ ).toBeUndefined();
+ });
+
+ it("uses app-server model/list reasoning metadata as authoritative", () => {
+ const provider = buildCodexProvider();
+ const levels = (modelId: string, supportedReasoningEfforts: string[]) =>
+ provider
+ .resolveThinkingProfile?.({
+ provider: "codex",
+ modelId,
+ compat: { supportedReasoningEfforts },
+ } as never)
+ ?.levels.map((level) => level.id);
+
+ const maxEfforts = ["low", "medium", "high", "xhigh", "max"];
+ const ultraEfforts = [...maxEfforts, "ultra"];
+ expect(levels("gpt-5.6-sol", maxEfforts)).not.toContain("ultra");
+ expect(levels("gpt-5.6-terra", maxEfforts)).not.toContain("ultra");
+ expect(levels("gpt-5.6-sol", ultraEfforts)).toContain("ultra");
+ expect(levels("gpt-5.6-terra", ultraEfforts)).toContain("ultra");
+ expect(levels("gpt-5.6-luna", maxEfforts)).not.toContain("ultra");
+ });
+
+ it.each([
+ ["max", ["low", "medium", "high", "xhigh", "ultra"], "xhigh"],
+ ["xhigh", ["low", "medium", "high", "ultra"], "high"],
+ ] as const)(
+ "does not upgrade requested %s to Ultra when model metadata omits that effort",
+ (requested, supportedReasoningEfforts, expected) => {
+ expect(resolveCodexSupportedReasoningEffort({ requested, supportedReasoningEfforts })).toBe(
+ expected,
+ );
+ },
+ );
+
it.each(["gpt-5.5-pro", "gpt-5.4-pro"] as const)(
"uses the known %s effort profile when app-server metadata is absent",
(modelId) => {
diff --git a/extensions/codex/provider.ts b/extensions/codex/provider.ts
index bee5cba9da5c..f3251f870cfa 100644
--- a/extensions/codex/provider.ts
+++ b/extensions/codex/provider.ts
@@ -35,11 +35,28 @@ const MODEL_DISCOVERY_PAGE_LIMIT = 100;
const CODEX_APP_SERVER_SETUP_METHOD_ID = "app-server";
const CODEX_DEFAULT_MODEL_REF = `${CODEX_PROVIDER_ID}/${FALLBACK_CODEX_MODELS[0].id}`;
const codexCatalogLog = createSubsystemLogger("codex/catalog");
-const CODEX_REASONING_EFFORTS = ["minimal", "low", "medium", "high", "xhigh", "max"] as const;
-const GPT_5_PRO_REASONING_EFFORTS = ["medium", "high", "xhigh"] as const;
-
+const CODEX_REASONING_EFFORTS = [
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+ "ultra",
+] as const;
export type CodexReasoningEffort = (typeof CODEX_REASONING_EFFORTS)[number];
+const GPT_56_MAX_REASONING_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
+const GPT_56_ULTRA_REASONING_EFFORTS = [...GPT_56_MAX_REASONING_EFFORTS, "ultra"] as const;
+const GPT_56_ULTRA_MODEL_IDS = new Set(["gpt-5.6-sol", "gpt-5.6-terra"]);
+const GPT_56_MAX_MODEL_IDS = new Set([...GPT_56_ULTRA_MODEL_IDS, "gpt-5.6-luna"]);
+const GPT_56_DEFAULT_REASONING_EFFORTS = new Map([
+ ["gpt-5.6-sol", "low"],
+ ["gpt-5.6-terra", "medium"],
+ ["gpt-5.6-luna", "medium"],
+]);
+const GPT_5_PRO_REASONING_EFFORTS = ["medium", "high", "xhigh"] as const;
+
type CodexModelLister = (options: {
timeoutMs: number;
limit?: number;
@@ -137,15 +154,17 @@ export function buildCodexProvider(options: BuildCodexProviderOptions = {}): Pro
});
return buildCodexAppServerUsageSnapshot(rateLimits);
},
- resolveThinkingProfile: ({ modelId, compat }) => ({
- levels: [
- { id: "off" },
- ...resolveCodexThinkingEfforts({
- modelId,
- supportedReasoningEfforts: readCodexSupportedReasoningEfforts(compat),
- }).map((id) => ({ id })),
- ],
- }),
+ resolveThinkingProfile: ({ modelId, compat }) => {
+ const efforts = resolveCodexThinkingEfforts({
+ modelId,
+ supportedReasoningEfforts: readCodexSupportedReasoningEfforts(compat),
+ });
+ const defaultLevel = GPT_56_DEFAULT_REASONING_EFFORTS.get(modelId.trim().toLowerCase());
+ return {
+ levels: [{ id: "off" }, ...efforts.map((id) => ({ id }))],
+ ...(defaultLevel && efforts.includes(defaultLevel) ? { defaultLevel } : {}),
+ };
+ },
resolveSystemPromptContribution: ({ config, modelId }) =>
resolveCodexSystemPromptContribution({ config, modelId }),
isModernModelRef: ({ modelId }) => isModernCodexModel(modelId),
@@ -301,7 +320,10 @@ export function readCodexSupportedReasoningEfforts(compat: unknown): string[] |
if (!Array.isArray(efforts)) {
return undefined;
}
- return efforts.filter((effort): effort is string => typeof effort === "string");
+ const strings = efforts.filter((effort): effort is string => typeof effort === "string");
+ // Direct OpenAI Responses metadata advertises `none`; Codex model/list does
+ // not. Do not let the direct API contract override native Codex capabilities.
+ return strings.some((effort) => effort.trim().toLowerCase() === "none") ? undefined : strings;
}
function resolveCodexThinkingEfforts(params: {
@@ -334,10 +356,14 @@ export function resolveCodexSupportedReasoningEffort(params: {
if (supported.includes(params.requested)) {
return params.requested;
}
+ // Ultra enables proactive multi-agent behavior, so it must be explicit.
+ // Lower-effort fallback may select Max or below, never Ultra.
+ const fallbackEfforts =
+ params.requested === "ultra" ? supported : supported.filter((effort) => effort !== "ultra");
const requestedRank = CODEX_REASONING_EFFORTS.indexOf(params.requested);
return (
- supported.find((effort) => CODEX_REASONING_EFFORTS.indexOf(effort) >= requestedRank) ??
- supported.at(-1)
+ fallbackEfforts.find((effort) => CODEX_REASONING_EFFORTS.indexOf(effort) >= requestedRank) ??
+ fallbackEfforts.at(-1)
);
}
@@ -346,17 +372,23 @@ export function resolveCodexFallbackReasoningEfforts(
modelId: string,
): readonly CodexReasoningEffort[] | undefined {
const normalized = modelId.trim().toLowerCase();
- return normalized === "gpt-5.5-pro" || normalized === "gpt-5.4-pro"
- ? GPT_5_PRO_REASONING_EFFORTS
- : undefined;
+ if (GPT_56_ULTRA_MODEL_IDS.has(normalized)) {
+ return GPT_56_ULTRA_REASONING_EFFORTS;
+ }
+ if (normalized === "gpt-5.6-luna") {
+ return GPT_56_MAX_REASONING_EFFORTS;
+ }
+ if (normalized === "gpt-5.5-pro" || normalized === "gpt-5.4-pro") {
+ return GPT_5_PRO_REASONING_EFFORTS;
+ }
+ return undefined;
}
/** Return whether the model uses the modern Codex reasoning profile. */
export function isModernCodexModel(modelId: string): boolean {
const lower = modelId.trim().toLowerCase();
return (
- lower === "gpt-5.6" ||
- lower.startsWith("gpt-5.6-") ||
+ GPT_56_MAX_MODEL_IDS.has(lower) ||
lower === "gpt-5.5" ||
lower === "gpt-5.5-pro" ||
lower === "gpt-5.4" ||
@@ -369,5 +401,5 @@ export function isModernCodexModel(modelId: string): boolean {
/** Return whether Codex accepts the preview GPT-5.6 `max` reasoning effort. */
export function isMaxReasoningCodexModel(modelId: string): boolean {
const lower = modelId.trim().toLowerCase();
- return lower === "gpt-5.6" || lower.startsWith("gpt-5.6-");
+ return GPT_56_MAX_MODEL_IDS.has(lower);
}
diff --git a/extensions/codex/src/app-server/native-subagent-monitor.test.ts b/extensions/codex/src/app-server/native-subagent-monitor.test.ts
index 82bfe741d275..4e73b981e372 100644
--- a/extensions/codex/src/app-server/native-subagent-monitor.test.ts
+++ b/extensions/codex/src/app-server/native-subagent-monitor.test.ts
@@ -233,6 +233,60 @@ describe("CodexNativeSubagentMonitor", () => {
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
});
+ it("registers Codex multi-agent V2 children from subagent activity", async () => {
+ const client = createClient();
+ const runtime = createRuntime();
+ const monitor = new CodexNativeSubagentMonitor(client, runtime);
+ monitor.registerParent({
+ parentThreadId: "parent-thread",
+ requesterSessionKey: "agent:main:main",
+ taskRuntimeScope: createTaskScope("agent:main:main"),
+ agentId: "main",
+ });
+
+ await client.notify({
+ method: "item/completed",
+ params: {
+ threadId: "parent-thread",
+ item: {
+ type: "subAgentActivity",
+ id: "activity-started",
+ kind: "started",
+ agentThreadId: "child-v2",
+ agentPath: "/root/researcher",
+ },
+ },
+ });
+ await client.notify(
+ nativeCompletionNotification({
+ agentPath: "/root/researcher",
+ statusLabel: "completed",
+ result: "child v2 result",
+ }),
+ );
+
+ expect(runtime.createRunningTaskRun).toHaveBeenCalledWith(
+ expect.objectContaining({
+ runId: "codex-thread:child-v2",
+ task: "Codex native subagent /root/researcher",
+ }),
+ );
+ expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith(
+ expect.objectContaining({
+ runId: "codex-thread:child-v2",
+ status: "succeeded",
+ terminalSummary: "child v2 result",
+ }),
+ );
+ expect(runtime.deliverAgentHarnessTaskCompletion).toHaveBeenCalledWith(
+ expect.objectContaining({
+ childSessionId: "child-v2",
+ result: "child v2 result",
+ }),
+ );
+ monitor.dispose();
+ });
+
it.each([
{ label: "remote V1", codexHome: undefined, finalizes: true },
{ label: "local transcript-backed V1", codexHome: "/tmp/codex-home", finalizes: false },
diff --git a/extensions/codex/src/app-server/native-subagent-monitor.ts b/extensions/codex/src/app-server/native-subagent-monitor.ts
index 5d5efa5a03cd..3018537057d5 100644
--- a/extensions/codex/src/app-server/native-subagent-monitor.ts
+++ b/extensions/codex/src/app-server/native-subagent-monitor.ts
@@ -321,6 +321,20 @@ export class CodexNativeSubagentMonitor {
: undefined;
const state = parentThreadId ? this.parentStates.get(parentThreadId) : undefined;
if (state && parentThreadId) {
+ // Codex multi-agent V2 exposes the child only through this parent-scoped
+ // activity item; its later wait item has no receiver thread ids.
+ if (
+ notification.method === "item/completed" &&
+ readString(item, "type") === "subAgentActivity"
+ ) {
+ const childThreadId = readString(item, "agentThreadId")?.trim();
+ if (childThreadId) {
+ this.registerChildThread(parentThreadId, childThreadId, {
+ agentPath: readString(item, "agentPath"),
+ });
+ }
+ return state;
+ }
const isSpawnAgentTool = normalizeToolName(readString(item, "tool")) === "spawnagent";
const childThreadIds = isSpawnAgentTool
? new Set([
diff --git a/extensions/codex/src/app-server/native-subagent-task-mirror.test.ts b/extensions/codex/src/app-server/native-subagent-task-mirror.test.ts
index 93ca2c42c935..e26eb12a29b8 100644
--- a/extensions/codex/src/app-server/native-subagent-task-mirror.test.ts
+++ b/extensions/codex/src/app-server/native-subagent-task-mirror.test.ts
@@ -291,6 +291,79 @@ describe("CodexNativeSubagentTaskMirror", () => {
expect(runtime.finalizeTaskRunByRunId).not.toHaveBeenCalled();
});
+ it("mirrors Codex multi-agent V2 activity lifecycle", () => {
+ const runtime = createRuntime();
+ const mirror = new CodexNativeSubagentTaskMirror(
+ {
+ parentThreadId: "parent-thread",
+ requesterSessionKey: "agent:main:main",
+ agentId: "main",
+ now: () => 41_000,
+ },
+ runtime,
+ );
+ for (const kind of ["started", "interacted", "interrupted"] as const) {
+ for (const method of ["item/started", "item/completed"] as const) {
+ mirror.handleNotification({
+ method,
+ params: {
+ threadId: "parent-thread",
+ item: {
+ type: "subAgentActivity",
+ id: `activity-${kind}`,
+ kind,
+ agentThreadId: "child-v2",
+ agentPath: "/root/researcher",
+ },
+ },
+ });
+ }
+ }
+ for (const threadId of ["parent-thread", "other-parent"]) {
+ mirror.handleNotification({
+ method: "item/completed",
+ params: {
+ threadId,
+ item: {
+ type: "subAgentActivity",
+ kind: "started",
+ agentThreadId: threadId === "parent-thread" ? "child-v2" : "other-child",
+ agentPath: "/root/researcher",
+ },
+ },
+ });
+ }
+
+ expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledTimes(1);
+ expect(runtime.tryCreateRunningTaskRun).toHaveBeenCalledWith({
+ sourceId: "codex-thread:child-v2",
+ agentId: "main",
+ runId: "codex-thread:child-v2",
+ label: "Codex subagent",
+ task: "Codex native subagent /root/researcher",
+ notifyPolicy: "silent",
+ deliveryStatus: "not_applicable",
+ preferMetadata: true,
+ startedAt: 41_000,
+ lastEventAt: 41_000,
+ progressSummary: "Codex native subagent started.",
+ });
+ expect(runtime.recordTaskRunProgressByRunId).toHaveBeenCalledWith({
+ runId: "codex-thread:child-v2",
+ lastEventAt: 41_000,
+ progressSummary: "Codex native subagent received more input.",
+ });
+ expect(runtime.finalizeTaskRunByRunId).toHaveBeenCalledWith({
+ runId: "codex-thread:child-v2",
+ status: "cancelled",
+ endedAt: 41_000,
+ lastEventAt: 41_000,
+ error: "Codex native subagent was interrupted.",
+ progressSummary: "Codex native subagent was interrupted.",
+ terminalSummary: "Codex native subagent was interrupted.",
+ });
+ });
+
it("uses the notification thread id when collab agent items omit sender thread id", () => {
const runtime = createRuntime();
const mirror = new CodexNativeSubagentTaskMirror(
diff --git a/extensions/codex/src/app-server/native-subagent-task-mirror.ts b/extensions/codex/src/app-server/native-subagent-task-mirror.ts
index f0cdac7cf942..a4b04b85d615 100644
--- a/extensions/codex/src/app-server/native-subagent-task-mirror.ts
+++ b/extensions/codex/src/app-server/native-subagent-task-mirror.ts
@@ -76,6 +76,15 @@ export class CodexNativeSubagentTaskMirror {
return;
}
if (notification.method === "item/started" || notification.method === "item/completed") {
+ const item = isJsonObject(params.item) ? params.item : undefined;
+ if (
+ notification.method === "item/completed" &&
+ item &&
+ readString(item, "type") === "subAgentActivity"
+ ) {
+ this.handleSubagentActivityItem(params);
+ return;
+ }
this.handleCollabAgentItem(params);
}
}
@@ -91,11 +100,6 @@ export class CodexNativeSubagentTaskMirror {
return;
}
const threadId = thread.id.trim();
- if (!threadId || this.mirrorStateByThreadId.get(threadId) === "mirrored") {
- return;
- }
- this.mirrorStateByThreadId.set(threadId, "mirrored");
- const runId = codexNativeSubagentRunId(threadId);
const label =
trimOptional(spawn.agent_nickname) ??
trimOptional(thread.agentNickname) ??
@@ -106,25 +110,17 @@ export class CodexNativeSubagentTaskMirror {
trimOptional(thread.preview) ??
`Codex native subagent${label === "Codex subagent" ? "" : ` ${label}`}`;
const createdAt = secondsToMillis(thread.createdAt) ?? this.now();
- const taskRecord = this.runtime.tryCreateRunningTaskRun({
- sourceId: runId,
- agentId: this.params.agentId,
- runId,
- label,
- task,
- notifyPolicy: "silent",
- deliveryStatus: "not_applicable",
- preferMetadata: true,
- startedAt: createdAt,
- lastEventAt: this.now(),
- progressSummary: "Codex native subagent started.",
- });
- if (!taskRecord) {
- this.mirrorStateByThreadId.set(threadId, "failed");
+ if (
+ !this.createRunningTask({
+ threadId,
+ label,
+ task,
+ startedAt: createdAt,
+ progressSummary: "Codex native subagent started.",
+ })
+ ) {
return;
}
- this.terminalRunIds.delete(runId);
- this.authoritativeRunIds.delete(runId);
this.applyStatus(threadId, thread.status);
}
@@ -244,34 +240,94 @@ export class CodexNativeSubagentTaskMirror {
}
}
- private createTaskFromCollabSpawnItem(threadId: string, item: JsonObject): void {
- const normalizedThreadId = threadId.trim();
- if (!normalizedThreadId || this.mirrorStateByThreadId.get(normalizedThreadId) === "mirrored") {
+ private handleSubagentActivityItem(params: JsonObject): void {
+ const item = isJsonObject(params.item) ? params.item : undefined;
+ if (
+ !item ||
+ readString(item, "type") !== "subAgentActivity" ||
+ readString(params, "threadId") !== this.params.parentThreadId
+ ) {
return;
}
- this.mirrorStateByThreadId.set(normalizedThreadId, "mirrored");
+ const threadId = trimOptional(readString(item, "agentThreadId"));
+ const kind = normalizeSubagentActivityKind(readString(item, "kind"));
+ if (!threadId || !kind) {
+ return;
+ }
+ if (kind === "started") {
+ this.createTaskFromSubagentActivity(threadId, trimOptional(readString(item, "agentPath")));
+ return;
+ }
+ if (this.mirrorStateByThreadId.get(threadId) !== "mirrored") {
+ return;
+ }
+ const message =
+ kind === "interacted"
+ ? "Codex native subagent received more input."
+ : "Codex native subagent was interrupted.";
+ this.applyCollabAgentStatus(
+ threadId,
+ kind === "interacted" ? "running" : "interrupted",
+ message,
+ );
+ }
+
+ private createTaskFromSubagentActivity(threadId: string, agentPath: string | undefined): void {
+ const eventAt = this.now();
+ this.createRunningTask({
+ threadId,
+ label: "Codex subagent",
+ task: agentPath ? `Codex native subagent ${agentPath}` : "Codex native subagent",
+ startedAt: eventAt,
+ progressSummary: "Codex native subagent started.",
+ });
+ }
+
+ private createTaskFromCollabSpawnItem(threadId: string, item: JsonObject): void {
const prompt = trimOptional(readString(item, "prompt"));
- const runId = codexNativeSubagentRunId(normalizedThreadId);
const createdAt = this.now();
+ this.createRunningTask({
+ threadId,
+ label: "Codex subagent",
+ task: prompt ?? "Codex native subagent",
+ startedAt: createdAt,
+ progressSummary: "Codex native subagent spawned.",
+ });
+ }
+
+ private createRunningTask(params: {
+ threadId: string;
+ label: string;
+ task: string;
+ startedAt: number;
+ progressSummary: string;
+ }): boolean {
+ const threadId = params.threadId.trim();
+ if (!threadId || this.mirrorStateByThreadId.get(threadId) === "mirrored") {
+ return false;
+ }
+ this.mirrorStateByThreadId.set(threadId, "mirrored");
+ const runId = codexNativeSubagentRunId(threadId);
const taskRecord = this.runtime.tryCreateRunningTaskRun({
sourceId: runId,
agentId: this.params.agentId,
runId,
- label: "Codex subagent",
- task: prompt ?? "Codex native subagent",
+ label: params.label,
+ task: params.task,
notifyPolicy: "silent",
deliveryStatus: "not_applicable",
preferMetadata: true,
- startedAt: createdAt,
- lastEventAt: createdAt,
- progressSummary: "Codex native subagent spawned.",
+ startedAt: params.startedAt,
+ lastEventAt: this.now(),
+ progressSummary: params.progressSummary,
});
if (!taskRecord) {
- this.mirrorStateByThreadId.set(normalizedThreadId, "failed");
- return;
+ this.mirrorStateByThreadId.set(threadId, "failed");
+ return false;
}
this.terminalRunIds.delete(runId);
this.authoritativeRunIds.delete(runId);
+ return true;
}
private applyCollabAgentStatus(
@@ -451,6 +507,13 @@ function normalizeToolName(value: string | undefined): string | undefined {
return value?.replace(/[^a-z0-9]/giu, "").toLowerCase();
}
+function normalizeSubagentActivityKind(
+ value: string | undefined,
+): "started" | "interacted" | "interrupted" | undefined {
+ const key = value?.replace(/[^a-z]/giu, "").toLowerCase();
+ return key === "started" || key === "interacted" || key === "interrupted" ? key : undefined;
+}
+
function normalizeCollabToolCallStatus(value: string | undefined): string | undefined {
const key = value?.replace(/[^a-z0-9]/giu, "").toLowerCase();
if (key === "completed" || key === "succeeded" || key === "success") {
diff --git a/extensions/codex/src/app-server/run-attempt.ts b/extensions/codex/src/app-server/run-attempt.ts
index aa45e9f48311..dab0806a56a3 100644
--- a/extensions/codex/src/app-server/run-attempt.ts
+++ b/extensions/codex/src/app-server/run-attempt.ts
@@ -2811,6 +2811,16 @@ export async function runCodexAppServerAttempt(
latestStartupErrorNotification = undefined;
rateLimitsRevisionBeforeLastTurnStart = readCodexRateLimitsRevision(client);
activeTurnRoute.armTurn();
+ void emitCodexAppServerEvent(params, {
+ stream: "codex_app_server.lifecycle",
+ data: {
+ phase: "turn_starting",
+ threadId: thread.threadId,
+ model: turnStartParams.model,
+ effort: turnStartParams.effort,
+ collaborationEffort: turnStartParams.collaborationMode?.settings.reasoning_effort,
+ },
+ });
let acceptedTurnId: string | undefined;
try {
const startedTurn = assertCodexTurnStartResponse(
@@ -2874,10 +2884,6 @@ export async function runCodexAppServerAttempt(
ctx: hookContext,
hookRunner,
});
- void emitCodexAppServerEvent(params, {
- stream: "codex_app_server.lifecycle",
- data: { phase: "turn_starting", threadId: thread.threadId },
- });
turn = await startCodexTurn();
} catch (error) {
let turnStartError = error;
diff --git a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts
index c045bc57e92f..fc8a5bfdf9ac 100644
--- a/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts
+++ b/extensions/codex/src/app-server/thread-lifecycle.binding.test.ts
@@ -372,6 +372,89 @@ describe("Codex app-server thread lifecycle bindings", () => {
expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start", "thread/resume"]);
});
+ it.each([
+ ["gpt-5.6-luna", "gpt-5.6-sol"],
+ ["gpt-5.6-luna", "gpt-5.6-terra"],
+ ["gpt-5.6-sol", "gpt-5.6-luna"],
+ ["gpt-5.6-terra", "gpt-5.6-luna"],
+ ])("starts a fresh thread when switching from %s to %s", async (bindingModel, requestedModel) => {
+ const sessionFile = path.join(tempDir, `${bindingModel}-${requestedModel}.jsonl`);
+ const workspaceDir = path.join(tempDir, "workspace");
+ await writeCodexAppServerBinding(sessionFile, {
+ threadId: "thread-existing",
+ cwd: workspaceDir,
+ model: bindingModel,
+ });
+ const params = createParams(sessionFile, workspaceDir);
+ params.modelId = requestedModel;
+ const request = vi.fn(async (method: string, requestParams?: unknown) => {
+ if (method === "thread/start") {
+ const response = threadStartResult("thread-rebound");
+ response.model = (requestParams as { model: string }).model;
+ return response;
+ }
+ throw new Error(`unexpected method: ${method}`);
+ });
+
+ const binding = await startOrResumeThread({
+ client: { request } as never,
+ params,
+ cwd: workspaceDir,
+ dynamicTools: [],
+ appServer: createThreadLifecycleAppServerOptions(),
+ });
+
+ expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/start"]);
+ expect(request.mock.calls[0]?.[1]).toMatchObject({ model: requestedModel });
+ expect(binding).toMatchObject({
+ threadId: "thread-rebound",
+ model: requestedModel,
+ lifecycle: { action: "started" },
+ });
+ });
+
+ it.each([
+ ["gpt-5.6-sol", "gpt-5.6-terra"],
+ ["gpt-5.6-terra", "gpt-5.6-sol"],
+ ])("resumes the thread when switching from %s to %s", async (bindingModel, requestedModel) => {
+ const sessionFile = path.join(tempDir, `${bindingModel}-${requestedModel}.jsonl`);
+ const workspaceDir = path.join(tempDir, "workspace");
+ await writeCodexAppServerBinding(sessionFile, {
+ threadId: "thread-existing",
+ cwd: workspaceDir,
+ model: bindingModel,
+ });
+ const params = createParams(sessionFile, workspaceDir);
+ params.modelId = requestedModel;
+ const request = vi.fn(async (method: string, requestParams?: unknown) => {
+ if (method === "thread/resume") {
+ const response = threadStartResult("thread-existing");
+ response.model = (requestParams as { model: string }).model;
+ return response;
+ }
+ throw new Error(`unexpected method: ${method}`);
+ });
+
+ const binding = await startOrResumeThread({
+ client: { request } as never,
+ params,
+ cwd: workspaceDir,
+ dynamicTools: [],
+ appServer: createThreadLifecycleAppServerOptions(),
+ });
+
+ expect(request.mock.calls.map(([method]) => method)).toEqual(["thread/resume"]);
+ expect(request.mock.calls[0]?.[1]).toMatchObject({
+ threadId: "thread-existing",
+ model: requestedModel,
+ });
+ expect(binding).toMatchObject({
+ threadId: "thread-existing",
+ model: requestedModel,
+ lifecycle: { action: "resumed" },
+ });
+ });
+
it("sends canonical typed dynamic tools on thread start", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
diff --git a/extensions/codex/src/app-server/thread-lifecycle.test.ts b/extensions/codex/src/app-server/thread-lifecycle.test.ts
index 20a471d8cd5f..fcd40b46776a 100644
--- a/extensions/codex/src/app-server/thread-lifecycle.test.ts
+++ b/extensions/codex/src/app-server/thread-lifecycle.test.ts
@@ -1412,8 +1412,7 @@ describe("Codex app-server thread lifecycle timing", () => {
describe("resolveReasoningEffort (#71946)", () => {
describe("modern Codex models (none/low/medium/high/xhigh enum)", () => {
it.each([
- "gpt-5.6",
- "gpt-5.6-sol-oai",
+ "gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
@@ -1428,8 +1427,7 @@ describe("resolveReasoningEffort (#71946)", () => {
);
it.each([
- "gpt-5.6",
- "gpt-5.6-sol-oai",
+ "gpt-5.6-sol",
"gpt-5.6-terra",
"gpt-5.6-luna",
"gpt-5.5",
@@ -1496,13 +1494,84 @@ describe("resolveReasoningEffort (#71946)", () => {
expect(resolveReasoningEffort("adaptive", "gpt-4o")).toBeNull();
});
- it("passes max for the GPT-5.6 series", () => {
- expect(resolveReasoningEffort("max", "gpt-5.6")).toBe("max");
- expect(resolveReasoningEffort("max", "gpt-5.6-sol-oai")).toBe("max");
+ it("passes max only for known native GPT-5.6 models", () => {
+ expect(resolveReasoningEffort("max", "gpt-5.6-sol")).toBe("max");
expect(resolveReasoningEffort("max", "gpt-5.6-terra")).toBe("max");
expect(resolveReasoningEffort("max", "gpt-5.6-luna")).toBe("max");
+ expect(resolveReasoningEffort("max", "gpt-5.6")).toBeNull();
+ expect(resolveReasoningEffort("max", "gpt-5.6-sol-oai")).toBeNull();
expect(resolveReasoningEffort("max", "gpt-5.5")).toBeNull();
expect(resolveReasoningEffort("max", "gpt-4o")).toBeNull();
});
+
+ it("uses known GPT-5.6 fallbacks when app-server metadata is unavailable", () => {
+ const ultraEfforts = ["low", "medium", "high", "xhigh", "max", "ultra"];
+ const maxEfforts = ["low", "medium", "high", "xhigh", "max"];
+
+ expect(resolveReasoningEffort("ultra", "gpt-5.6-sol", ultraEfforts)).toBe("ultra");
+ expect(resolveReasoningEffort("ultra", "gpt-5.6-terra", ultraEfforts)).toBe("ultra");
+ expect(resolveReasoningEffort("ultra", "gpt-5.6-luna", maxEfforts)).toBe("max");
+ expect(resolveReasoningEffort("ultra", "gpt-5.6-sol")).toBe("ultra");
+ expect(resolveReasoningEffort("ultra", "gpt-5.6-terra")).toBe("ultra");
+ expect(resolveReasoningEffort("ultra", "gpt-5.6-luna")).toBe("max");
+ });
+ });
+});
+
+describe("native Codex Ultra turn mapping", () => {
+ it.each([
+ { modelId: "gpt-5.6-sol", expected: "ultra" },
+ { modelId: "gpt-5.6-terra", expected: "ultra" },
+ { modelId: "gpt-5.6-luna", expected: "max" },
+ ] as const)(
+ "maps Ultra to $expected for $modelId with direct OpenAI API metadata",
+ ({ modelId, expected }) => {
+ const params = createAttemptParams({
+ provider: "openai",
+ modelId,
+ authProfileId: "openai:api-key",
+ authProfileType: "api_key",
+ });
+ params.thinkLevel = "ultra" as EmbeddedRunAttemptParams["thinkLevel"];
+ params.model = {
+ ...createCodexTestModel("openai"),
+ id: modelId,
+ compat: {
+ supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
+ } as never,
+ };
+
+ const request = buildTurnStartParams(params, {
+ threadId: "thread-ultra",
+ cwd: "/repo",
+ appServer: createAppServerOptions() as never,
+ });
+
+ expect(request.effort).toBe(expected);
+ expect(request.collaborationMode?.settings.reasoning_effort).toBe(expected);
+ expect(request).not.toHaveProperty("multiAgentMode");
+ },
+ );
+
+ it("lets authoritative app-server model/list metadata override the fallback", () => {
+ const params = createAttemptParams({ provider: "codex", modelId: "gpt-5.6-sol" });
+ params.thinkLevel = "ultra" as EmbeddedRunAttemptParams["thinkLevel"];
+ params.model = {
+ ...createCodexTestModel("codex"),
+ id: "gpt-5.6-sol",
+ compat: {
+ supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"],
+ } as never,
+ };
+
+ const request = buildTurnStartParams(params, {
+ threadId: "thread-native-catalog",
+ cwd: "/repo",
+ appServer: createAppServerOptions() as never,
+ });
+
+ expect(request.effort).toBe("max");
+ expect(request.collaborationMode?.settings.reasoning_effort).toBe("max");
+ expect(request).not.toHaveProperty("multiAgentMode");
});
});
diff --git a/extensions/codex/src/app-server/thread-lifecycle.ts b/extensions/codex/src/app-server/thread-lifecycle.ts
index af77119eda7e..c32de45da56f 100644
--- a/extensions/codex/src/app-server/thread-lifecycle.ts
+++ b/extensions/codex/src/app-server/thread-lifecycle.ts
@@ -440,6 +440,26 @@ export async function startOrResumeThread(params: {
await clearCurrentBinding("rotating a stale thread binding");
binding = undefined;
}
+ if (
+ binding?.threadId &&
+ shouldRotateCodexGpt56MultiAgentBinding({
+ bindingModel: binding.model,
+ requestedModel: params.params.modelId,
+ })
+ ) {
+ // Codex locks the model-selected multi-agent version on the first turn.
+ // Sol/Terra (V2) and Luna (V1) therefore cannot share one resumed thread.
+ embeddedAgentLog.debug(
+ "codex app-server GPT-5.6 multi-agent version changed; starting a new thread",
+ {
+ threadId: binding.threadId,
+ bindingModel: binding.model,
+ requestedModel: params.params.modelId,
+ },
+ );
+ await clearCurrentBinding("rotating a GPT-5.6 multi-agent thread binding");
+ binding = undefined;
+ }
const startModelSelection = resolveCodexAppServerThreadModelSelection({
provider: params.params.provider,
model: params.params.modelId,
@@ -1022,6 +1042,38 @@ export function shouldRotateCodexAppServerBindingForRuntime(params: {
return params.connectionClass === "remote" || Boolean(params.binding);
}
+type CodexGpt56MultiAgentVersion = "v1" | "v2";
+
+function resolveCodexGpt56MultiAgentVersion(
+ modelRef: string | undefined,
+): CodexGpt56MultiAgentVersion | undefined {
+ let modelId = modelRef?.trim().toLowerCase();
+ if (!modelId) {
+ return undefined;
+ }
+ const slashIndex = modelId.indexOf("/");
+ if (slashIndex > 0) {
+ const provider = modelId.slice(0, slashIndex);
+ if (provider !== "openai" && provider !== "codex") {
+ return undefined;
+ }
+ modelId = modelId.slice(slashIndex + 1);
+ }
+ if (modelId === "gpt-5.6-sol" || modelId === "gpt-5.6-terra") {
+ return "v2";
+ }
+ return modelId === "gpt-5.6-luna" ? "v1" : undefined;
+}
+
+function shouldRotateCodexGpt56MultiAgentBinding(params: {
+ bindingModel?: string;
+ requestedModel: string;
+}): boolean {
+ const bindingVersion = resolveCodexGpt56MultiAgentVersion(params.bindingModel);
+ const requestedVersion = resolveCodexGpt56MultiAgentVersion(params.requestedModel);
+ return Boolean(bindingVersion && requestedVersion && bindingVersion !== requestedVersion);
+}
+
function isTransientWebSearchRestriction(
params: Pick<
Parameters[0],
@@ -1909,7 +1961,7 @@ export function resolveCodexAppServerModelProvider(params: {
// Other modern models translate `minimal` to `low`. (#71946)
// Exported for unit-test coverage of the model-aware translation path.
export function resolveReasoningEffort(
- thinkLevel: EmbeddedRunAttemptParams["thinkLevel"],
+ thinkLevel: EmbeddedRunAttemptParams["thinkLevel"] | "ultra",
modelId: string,
supportedReasoningEfforts?: readonly string[],
): CodexReasoningEffort | null {
diff --git a/extensions/discord/src/monitor/native-command-model-picker-ui.ts b/extensions/discord/src/monitor/native-command-model-picker-ui.ts
index 81be1b018e5b..0ab9f4f21739 100644
--- a/extensions/discord/src/monitor/native-command-model-picker-ui.ts
+++ b/extensions/discord/src/monitor/native-command-model-picker-ui.ts
@@ -1,6 +1,7 @@
// Discord plugin module implements native command model picker ui behavior.
import { resolveDefaultModelForAgent } from "openclaw/plugin-sdk/agent-runtime";
import {
+ resolveEffectiveAgentRuntime,
resolveStoredModelOverride,
serializeCommandArgs,
type ChatCommandDefinition,
@@ -182,7 +183,7 @@ export async function resolveDiscordNativeChoiceContext(params: {
cfg: OpenClawConfig;
accountId: string;
threadBindings: ThreadBindingManager;
-}): Promise<{ provider?: string; model?: string } | null> {
+}): Promise<{ provider?: string; model?: string; agentRuntime?: string } | null> {
try {
const resolved = await resolveDiscordModelPickerRouteState({
interaction: params.interaction,
@@ -209,15 +210,19 @@ export async function resolveDiscordNativeChoiceContext(params: {
sessionKey: route.sessionKey,
defaultProvider: fallback.provider,
});
- if (!override?.model) {
- return {
- provider: fallback.provider,
- model: fallback.model,
- };
- }
+ const provider = override?.provider || fallback.provider;
+ const model = override?.model || fallback.model;
return {
- provider: override.provider || fallback.provider,
- model: override.model,
+ provider,
+ model,
+ agentRuntime: resolveEffectiveAgentRuntime({
+ cfg: params.cfg,
+ provider,
+ modelId: model,
+ agentId: route.agentId,
+ sessionKey: route.sessionKey,
+ sessionEntry,
+ }),
};
} catch {
return null;
diff --git a/extensions/discord/src/monitor/native-command.options.test.ts b/extensions/discord/src/monitor/native-command.options.test.ts
index c130a2177df0..46f59a0105bb 100644
--- a/extensions/discord/src/monitor/native-command.options.test.ts
+++ b/extensions/discord/src/monitor/native-command.options.test.ts
@@ -1,5 +1,6 @@
// Discord tests cover native command.options plugin behavior.
import { ApplicationCommandType, ChannelType, InteractionContextType } from "discord-api-types/v10";
+import type { ChatCommandDefinition } from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
clearRuntimeConfigSnapshot,
@@ -39,6 +40,7 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
let listNativeCommandSpecs: typeof import("openclaw/plugin-sdk/command-auth-native").listNativeCommandSpecs;
let createDiscordNativeCommand: typeof import("./native-command.js").createDiscordNativeCommand;
+let buildDiscordCommandOptions: typeof import("./native-command.options.js").buildDiscordCommandOptions;
let nativeCommandTesting: typeof import("./native-command.js").testing;
let resolveDiscordNativeAutocompleteAuthorized: typeof import("./native-command-auth.js").resolveDiscordNativeAutocompleteAuthorized;
let createNoopThreadBindingManager: typeof import("./thread-bindings.js").createNoopThreadBindingManager;
@@ -223,6 +225,7 @@ describe("createDiscordNativeCommand option wiring", () => {
({ listNativeCommandSpecs } = await import("openclaw/plugin-sdk/command-auth-native"));
({ createDiscordNativeCommand, testing: nativeCommandTesting } =
await import("./native-command.js"));
+ ({ buildDiscordCommandOptions } = await import("./native-command.options.js"));
({ resolveDiscordNativeAutocompleteAuthorized } = await import("./native-command-auth.js"));
({ createNoopThreadBindingManager } = await import("./thread-bindings.js"));
});
@@ -284,6 +287,63 @@ describe("createDiscordNativeCommand option wiring", () => {
expect(loadModelCatalogMock).toHaveBeenCalledWith({ config: cfg });
});
+ it("passes the effective agent runtime into dynamic /think choices", async () => {
+ let agentRuntime = "codex";
+ const command: ChatCommandDefinition = {
+ key: "think",
+ nativeName: "think",
+ description: "Set thinking level",
+ textAliases: ["/think"],
+ acceptsArgs: true,
+ args: [
+ {
+ name: "level",
+ description: "Thinking level",
+ type: "string",
+ choices: ({ agentRuntime: selectedRuntime }) => [
+ "max",
+ ...(selectedRuntime === "openclaw" ? ["ultra"] : []),
+ ],
+ },
+ ],
+ argsParsing: "positional",
+ argsMenu: "auto",
+ scope: "both",
+ };
+ const options = buildDiscordCommandOptions({
+ command,
+ cfg: {},
+ authorizeChoiceContext: async () => true,
+ resolveChoiceContext: async () => ({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime,
+ }),
+ });
+ const level = options?.find((option) => option.name === "level");
+ if (!level) {
+ throw new Error("missing runtime-aware thinking option");
+ }
+ const autocomplete = requireAutocomplete(level, "think level option did not wire autocomplete");
+ const params = {
+ userId: "owner",
+ channelType: ChannelType.DM,
+ channelId: "dm-1",
+ channelName: "dm-1",
+ focusedValue: "",
+ } as const;
+
+ const codexRespond = await runAutocomplete(autocomplete, params);
+ expect(codexRespond).toHaveBeenCalledWith([{ name: "max", value: "max" }]);
+
+ agentRuntime = "openclaw";
+ const openclawRespond = await runAutocomplete(autocomplete, params);
+ expect(openclawRespond).toHaveBeenCalledWith([
+ { name: "max", value: "max" },
+ { name: "ultra", value: "ultra" },
+ ]);
+ });
+
it("keeps static choices for non-acp string action arguments", () => {
const command = createNativeCommand("config");
const action = requireOption(command, "action");
diff --git a/extensions/discord/src/monitor/native-command.options.ts b/extensions/discord/src/monitor/native-command.options.ts
index 0e85df2df521..4c53922c6b07 100644
--- a/extensions/discord/src/monitor/native-command.options.ts
+++ b/extensions/discord/src/monitor/native-command.options.ts
@@ -64,7 +64,7 @@ export function buildDiscordCommandOptions(params: {
authorizeChoiceContext?: (interaction: AutocompleteInteraction) => Promise;
resolveChoiceContext?: (
interaction: AutocompleteInteraction,
- ) => Promise<{ provider?: string; model?: string } | null>;
+ ) => Promise<{ provider?: string; model?: string; agentRuntime?: string } | null>;
}): CommandOptions | undefined {
const { command, cfg, resolveConfig, authorizeChoiceContext, resolveChoiceContext } = params;
const commandLabel = resolveDiscordCommandLogLabel(command);
@@ -129,6 +129,7 @@ export function buildDiscordCommandOptions(params: {
cfg: currentCfg,
provider: context?.provider,
model: context?.model,
+ agentRuntime: context?.agentRuntime,
...(choiceCatalog?.length ? { catalog: choiceCatalog } : {}),
});
const filtered = focusValue
diff --git a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts
index f031a7e7edeb..e8017fdc3c01 100644
--- a/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts
+++ b/extensions/discord/src/monitor/native-command.think-autocomplete.test.ts
@@ -137,6 +137,7 @@ let resolveDiscordNativeChoiceContext: typeof import("./native-command-model-pic
async function saveSessionOverride(params: {
providerOverride: string;
modelOverride: string;
+ agentRuntimeOverride?: string;
}): Promise {
fs.mkdirSync(path.dirname(STORE_PATH), { recursive: true });
await saveSessionStore(
@@ -147,6 +148,9 @@ async function saveSessionOverride(params: {
updatedAt: Date.now(),
providerOverride: params.providerOverride,
modelOverride: params.modelOverride,
+ ...(params.agentRuntimeOverride
+ ? { agentRuntimeOverride: params.agentRuntimeOverride }
+ : {}),
},
} satisfies Record,
{ skipMaintenance: true },
@@ -314,6 +318,7 @@ describe("discord native /think autocomplete", () => {
expect(context).toEqual({
provider: "openai",
model: "gpt-5.4",
+ agentRuntime: "codex",
});
const choices = resolveCommandArgChoices({
@@ -322,6 +327,7 @@ describe("discord native /think autocomplete", () => {
cfg,
provider: context?.provider,
model: context?.model,
+ agentRuntime: context?.agentRuntime,
catalog: [],
});
const values = choices.map((choice) => choice.value);
@@ -330,6 +336,66 @@ describe("discord native /think autocomplete", () => {
expect(values).not.toContain("adaptive");
});
+ it.each([
+ { sessionRuntime: undefined, expectedRuntime: "codex", supportsUltra: false },
+ { sessionRuntime: "openclaw", expectedRuntime: "openclaw", supportsUltra: true },
+ ])(
+ "uses the effective $expectedRuntime runtime for Luna choices",
+ async ({ sessionRuntime, expectedRuntime, supportsUltra }) => {
+ providerThinkingMocks.resolveProviderThinkingProfile.mockImplementation(
+ ({ provider, context }) =>
+ provider === "openai" && context.modelId === "gpt-5.6-luna"
+ ? {
+ levels: [
+ { id: "off" },
+ { id: "max" },
+ ...(context.agentRuntime === "openclaw" ? [{ id: "ultra" as const }] : []),
+ ],
+ }
+ : undefined,
+ );
+ await saveSessionOverride({
+ providerOverride: "openai",
+ modelOverride: "gpt-5.6-luna",
+ ...(sessionRuntime ? { agentRuntimeOverride: sessionRuntime } : {}),
+ });
+ const cfg = createConfig();
+ const interaction = {
+ options: { getFocused: () => ({ value: "" }) },
+ respond: async (_choices: Array<{ name: string; value: string }>) => {},
+ rawData: {},
+ channel: { id: "D1", type: ChannelType.DM },
+ user: { id: "U1" },
+ guild: undefined,
+ client: { fetchChannel: async () => ({ id: "D1", type: ChannelType.DM }) },
+ } as unknown as AutocompleteInteraction;
+
+ const context = await resolveDiscordNativeChoiceContext({
+ interaction,
+ cfg,
+ accountId: "default",
+ threadBindings: createNoopThreadBindingManager("default"),
+ });
+ expect(context).toEqual({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: expectedRuntime,
+ });
+
+ const { command, levelArg } = requireThinkLevelCommand();
+ const choices = resolveCommandArgChoices({
+ command,
+ arg: levelArg,
+ cfg,
+ provider: context?.provider,
+ model: context?.model,
+ agentRuntime: context?.agentRuntime,
+ catalog: [],
+ });
+ expect(choices.some((choice) => choice.value === "ultra")).toBe(supportsUltra);
+ },
+ );
+
it("includes max only for provider-advertised models", async () => {
providerThinkingMocks.resolveProviderThinkingProfile.mockImplementation(
({ provider, context }) =>
@@ -373,6 +439,7 @@ describe("discord native /think autocomplete", () => {
cfg,
provider: context?.provider,
model: context?.model,
+ agentRuntime: context?.agentRuntime,
catalog: [],
});
const values = choices.map((choice) => choice.value);
diff --git a/extensions/discord/src/monitor/native-command.ts b/extensions/discord/src/monitor/native-command.ts
index 7a7345a1e1b6..cc382cfa47ac 100644
--- a/extensions/discord/src/monitor/native-command.ts
+++ b/extensions/discord/src/monitor/native-command.ts
@@ -497,6 +497,7 @@ async function dispatchDiscordCommandInteraction(params: {
cfg,
provider: menuModelContext?.provider,
model: menuModelContext?.model,
+ agentRuntime: menuModelContext?.agentRuntime,
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
});
if (menu) {
diff --git a/extensions/llm-task/src/llm-task-tool.test.ts b/extensions/llm-task/src/llm-task-tool.test.ts
index ba60d08c503d..4fc05da50ce2 100644
--- a/extensions/llm-task/src/llm-task-tool.test.ts
+++ b/extensions/llm-task/src/llm-task-tool.test.ts
@@ -21,15 +21,24 @@ const runEmbeddedAgent = vi.fn(async () => ({
payloads: [{ text: "{}" }],
}));
-const resolveThinkingPolicy = vi.fn(() => ({
- levels: [
- { id: "off", label: "off" },
- { id: "minimal", label: "minimal" },
- { id: "low", label: "low" },
- { id: "medium", label: "medium" },
- { id: "high", label: "high" },
- ],
-}));
+const resolveThinkingPolicy = vi.fn(
+ ({ model, agentRuntime }: { model?: string | null; agentRuntime?: string | null }) => ({
+ levels: [
+ { id: "off", label: "off" },
+ { id: "minimal", label: "minimal" },
+ { id: "low", label: "low" },
+ { id: "medium", label: "medium" },
+ { id: "high", label: "high" },
+ ...(model?.startsWith("gpt-5.6") &&
+ (agentRuntime === "openclaw" || (agentRuntime === "codex" && !model.endsWith("-luna")))
+ ? [
+ { id: "max", label: "max" },
+ { id: "ultra", label: "ultra" },
+ ]
+ : []),
+ ],
+ }),
+);
const normalizeThinkingLevel = vi.fn((raw?: string | null) => {
const value = raw?.trim().toLowerCase();
@@ -39,7 +48,9 @@ const normalizeThinkingLevel = vi.fn((raw?: string | null) => {
if (value === "on") {
return "low";
}
- if (["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"].includes(value)) {
+ if (
+ ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max", "ultra"].includes(value)
+ ) {
return value;
}
return undefined;
@@ -51,7 +62,15 @@ function fakeApi(overrides: any = {}) {
name: "llm-task",
source: "test",
config: {
- agents: { defaults: { workspace: "/tmp", model: { primary: "openai/gpt-5.5" } } },
+ agents: {
+ defaults: {
+ workspace: "/tmp",
+ model: { primary: "openai/gpt-5.5" },
+ models: {
+ "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } },
+ },
+ },
+ },
},
pluginConfig: {},
runtime: {
@@ -251,9 +270,76 @@ describe("llm-task tool (json-only)", () => {
expect(resolveThinkingPolicy).toHaveBeenCalledWith({
provider: "openai",
model: "gpt-5.5",
+ agentRuntime: "openclaw",
});
});
+ it("lets a configured Codex runtime own Ultra validation and execution", async () => {
+ mockEmbeddedRunJson({ ok: true });
+ const config = {
+ agents: {
+ defaults: {
+ workspace: "/tmp",
+ model: { primary: "openai/gpt-5.6-sol" },
+ models: {
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ };
+ const tool = createLlmTaskTool(fakeApi({ config }));
+
+ await tool.execute("id", {
+ prompt: "x",
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ thinking: "ultra",
+ });
+
+ expect(resolveThinkingPolicy).toHaveBeenCalledWith({
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ agentRuntime: "codex",
+ });
+ const call = (runEmbeddedAgent as any).mock.calls[0]?.[0];
+ expect(call.thinkLevel).toBe("ultra");
+ expect(call.config).toBe(config);
+ expect(call.agentHarnessRuntimeOverride).toBe("codex");
+ });
+
+ it("lets an explicit OpenClaw model runtime own Luna Ultra", async () => {
+ mockEmbeddedRunJson({ ok: true });
+ const config = {
+ agents: {
+ defaults: {
+ workspace: "/tmp",
+ model: { primary: "openai/gpt-5.6-luna" },
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "openclaw" } },
+ },
+ },
+ },
+ };
+ const tool = createLlmTaskTool(fakeApi({ config }));
+
+ await tool.execute("id", {
+ prompt: "x",
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ thinking: "ultra",
+ });
+
+ expect(resolveThinkingPolicy).toHaveBeenCalledWith({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: "openclaw",
+ });
+ const call = (runEmbeddedAgent as any).mock.calls[0]?.[0];
+ expect(call.thinkLevel).toBe("ultra");
+ expect(call.config).toBe(config);
+ expect(call.agentHarnessRuntimeOverride).toBe("openclaw");
+ });
+
it("normalizes thinking aliases", async () => {
mockEmbeddedRunJson({ ok: true });
const call = await executeEmbeddedRun({ prompt: "x", thinking: "on" });
@@ -295,6 +381,7 @@ describe("llm-task tool (json-only)", () => {
mockEmbeddedRunJson({ ok: true });
const call = await executeEmbeddedRun({ prompt: "x" });
expect(call.disableTools).toBe(true);
+ expect(call.agentHarnessRuntimeOverride).toBe("openclaw");
});
it("rejects malformed numeric run options before dispatch", async () => {
diff --git a/extensions/llm-task/src/llm-task-tool.ts b/extensions/llm-task/src/llm-task-tool.ts
index f6b17bea7920..d6037269ac79 100644
--- a/extensions/llm-task/src/llm-task-tool.ts
+++ b/extensions/llm-task/src/llm-task-tool.ts
@@ -5,6 +5,7 @@ import {
optionalFiniteNumberSchema,
optionalPositiveIntegerSchema,
} from "openclaw/plugin-sdk/channel-actions";
+import { resolveEffectiveAgentRuntime } from "openclaw/plugin-sdk/command-auth-native";
import {
type JsonSchemaObject,
validateJsonSchemaValue,
@@ -210,12 +211,22 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
);
}
+ const agentRuntime = resolveEffectiveAgentRuntime({
+ cfg: api.config ?? {},
+ provider,
+ modelId: model,
+ });
+
const thinkingRaw =
typeof params.thinking === "string" && params.thinking.trim() ? params.thinking : undefined;
let thinkLevel: ReturnType =
undefined;
if (thinkingRaw) {
- const thinkingPolicy = api.runtime.agent.resolveThinkingPolicy({ provider, model });
+ const thinkingPolicy = api.runtime.agent.resolveThinkingPolicy({
+ provider,
+ model,
+ agentRuntime,
+ });
const thinkingLevelsHint = formatThinkingPolicy(thinkingPolicy);
thinkLevel = api.runtime.agent.normalizeThinkingLevel(thinkingRaw);
if (!thinkLevel) {
@@ -278,6 +289,7 @@ export function createLlmTaskTool(api: OpenClawPluginApi) {
model,
authProfileId,
authProfileIdSource: authProfileId ? "user" : "auto",
+ agentHarnessRuntimeOverride: agentRuntime,
thinkLevel,
streamParams,
disableTools: true,
diff --git a/extensions/openai/openai-chatgpt-provider.ts b/extensions/openai/openai-chatgpt-provider.ts
index 4d7d890b0c5e..579853e1a841 100644
--- a/extensions/openai/openai-chatgpt-provider.ts
+++ b/extensions/openai/openai-chatgpt-provider.ts
@@ -632,7 +632,8 @@ export function buildOpenAICodexProviderHooks(): Pick<
return {
resolveDynamicModel: (ctx) => resolveCodexForwardCompatModel(ctx),
buildAuthDoctorHint: (ctx) => buildOpenAICodexAuthDoctorHint(ctx),
- resolveThinkingProfile: ({ modelId }) => resolveOpenAICodexThinkingProfile(modelId),
+ resolveThinkingProfile: ({ modelId, agentRuntime, compat }) =>
+ resolveOpenAICodexThinkingProfile(modelId, agentRuntime, compat),
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_CODEX_MODERN_MODEL_IDS),
preferRuntimeResolvedModel: (ctx) => {
if (!isOpenAIOrLegacyCodexProvider(ctx.provider)) {
diff --git a/extensions/openai/openai-provider.test.ts b/extensions/openai/openai-provider.test.ts
index 46292bbf0fa8..8feeaace0b88 100644
--- a/extensions/openai/openai-provider.test.ts
+++ b/extensions/openai/openai-provider.test.ts
@@ -257,6 +257,9 @@ describe("buildOpenAIProvider", () => {
"none",
"none",
]);
+ expect(gpt56Models?.map((model) => model.compat?.supportedReasoningEfforts)).toEqual(
+ Array.from({ length: 4 }, () => ["none", "low", "medium", "high", "xhigh", "max"]),
+ );
expect(OPENAI_DEFAULT_MODEL).toBe("openai/gpt-5.6");
expect(OPENAI_CODEX_DEFAULT_MODEL).toBe("openai/gpt-5.6-sol");
});
@@ -513,6 +516,15 @@ describe("buildOpenAIProvider", () => {
},
thinkingLevelMap: { off: null, xhigh: "xhigh", max: "max" },
});
+ const liveSol = openai?.models.find((model) => model.id === "gpt-5.6-sol");
+ expect(
+ provider.resolveThinkingProfile?.({
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ agentRuntime: "codex",
+ compat: liveSol?.compat,
+ } as never)?.levels,
+ ).not.toContainEqual({ id: "ultra" });
expect(openai?.models.find((model) => model.id === "gpt-5.6-terra")).toMatchObject({
contextWindow: 372_000,
contextTokens: 372_000,
@@ -660,10 +672,47 @@ describe("buildOpenAIProvider", () => {
expect(release).toHaveBeenCalledOnce();
});
- it("keeps static OpenAI OAuth rows when Codex catalog discovery fails", async () => {
+ it("keeps an explicit empty Codex reasoning catalog authoritative", async () => {
+ const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async () => ({
+ response: Response.json({
+ models: [
+ {
+ slug: "gpt-5.6-sol",
+ display_name: "GPT-5.6 Sol",
+ visibility: "list",
+ supported_reasoning_levels: [],
+ },
+ ],
+ }),
+ finalUrl: "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
+ release: async () => undefined,
+ }));
+
+ const provider = await buildOpenAICodexLiveProviderConfig({
+ discoveryApiKey: "empty-reasoning-oauth-token",
+ fetchGuard,
+ });
+ const sol = provider.models.find((model) => model.id === "gpt-5.6-sol");
+
+ expect(sol?.compat?.supportedReasoningEfforts).toEqual([]);
+ expect(sol?.thinkingLevelMap).toEqual({ off: null });
+ expect(
+ buildOpenAIProvider().resolveThinkingProfile?.({
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ agentRuntime: "codex",
+ compat: sol?.compat,
+ } as never)?.levels,
+ ).not.toContainEqual({ id: "ultra" });
+ });
+
+ it.each([
+ ["fails", () => new Response("temporarily unavailable", { status: 503 })],
+ ["returns no models", () => Response.json({ models: [] })],
+ ])("keeps static OpenAI OAuth rows when Codex catalog discovery %s", async (_label, response) => {
const release = vi.fn(async () => undefined);
const fetchGuard: LiveModelCatalogFetchGuard = vi.fn(async () => ({
- response: new Response("temporarily unavailable", { status: 503 }),
+ response: response(),
finalUrl: "https://chatgpt.com/backend-api/codex/models?client_version=1.0.0",
release,
}));
@@ -683,17 +732,54 @@ describe("buildOpenAIProvider", () => {
contextWindow: 372_000,
contextTokens: 372_000,
thinkingLevelMap: { off: null },
- compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"] },
+ compat: {
+ supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
+ },
});
expect(provider.models.find((model) => model.id === "gpt-5.6-terra")).toMatchObject({
contextWindow: 372_000,
contextTokens: 372_000,
+ compat: {
+ supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
+ },
});
expect(provider.models.find((model) => model.id === "gpt-5.6-luna")).toMatchObject({
contextWindow: 372_000,
contextTokens: 372_000,
+ compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"] },
});
expect(provider.models.map((model) => model.id)).toContain("gpt-5.5");
+ const gpt56Models = Object.fromEntries(
+ provider.models
+ .filter((model) => model.id.startsWith("gpt-5.6-"))
+ .map((model) => [model.id, model]),
+ );
+ for (const modelId of ["gpt-5.6-sol", "gpt-5.6-terra"]) {
+ const model = gpt56Models[modelId];
+ expect(model?.compat?.supportedReasoningEfforts).toContain("ultra");
+ expect(
+ buildOpenAIProvider()
+ .resolveThinkingProfile?.({
+ provider: "openai",
+ modelId,
+ agentRuntime: "codex",
+ compat: model?.compat,
+ } as never)
+ ?.levels.map((level) => level.id),
+ ).toContain("ultra");
+ }
+ const luna = gpt56Models["gpt-5.6-luna"];
+ expect(luna?.compat?.supportedReasoningEfforts).not.toContain("ultra");
+ const lunaLevels = buildOpenAIProvider()
+ .resolveThinkingProfile?.({
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ agentRuntime: "codex",
+ compat: luna?.compat,
+ } as never)
+ ?.levels.map((level) => level.id);
+ expect(lunaLevels).toContain("max");
+ expect(lunaLevels).not.toContain("ultra");
expect(release).toHaveBeenCalledOnce();
});
@@ -1223,6 +1309,45 @@ describe("buildOpenAIProvider", () => {
).not.toContain("xhigh");
});
+ it("passes the selected runtime into GPT-5.6 thinking policy", () => {
+ const provider = buildOpenAIProvider();
+ const openClawLuna = provider.resolveThinkingProfile?.({
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ agentRuntime: "openclaw",
+ } as never);
+ const codexLuna = provider.resolveThinkingProfile?.({
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ agentRuntime: "codex",
+ compat: {
+ supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
+ },
+ } as never);
+ const codexSolFromDirectCatalog = provider.resolveThinkingProfile?.({
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ agentRuntime: "codex",
+ compat: {
+ supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
+ },
+ } as never);
+ const codexSolFromNativeCatalog = provider.resolveThinkingProfile?.({
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ agentRuntime: "codex",
+ compat: {
+ supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
+ },
+ } as never);
+
+ expect(openClawLuna?.levels.map((level) => level.id)).toContain("ultra");
+ expect(codexLuna?.levels.map((level) => level.id)).not.toContain("ultra");
+ expect(codexLuna?.levels.map((level) => level.id)).toContain("max");
+ expect(codexSolFromDirectCatalog?.levels.map((level) => level.id)).toContain("ultra");
+ expect(codexSolFromNativeCatalog?.levels.map((level) => level.id)).toContain("ultra");
+ });
+
it("keeps chat-latest and gpt-5.5 out of synthetic catalog metadata", () => {
const provider = buildOpenAIProvider();
diff --git a/extensions/openai/openai-provider.ts b/extensions/openai/openai-provider.ts
index 41ee88ab657a..87db4dc2d717 100644
--- a/extensions/openai/openai-provider.ts
+++ b/extensions/openai/openai-provider.ts
@@ -245,14 +245,14 @@ function readCodexModelStringArray(row: unknown, keys: readonly string[]): reado
return [];
}
-function readCodexReasoningLevels(row: unknown): readonly string[] {
+function readCodexReasoningLevels(row: unknown): readonly string[] | undefined {
if (!row || typeof row !== "object" || Array.isArray(row)) {
- return [];
+ return undefined;
}
const record = row as Record;
const value = record.supported_reasoning_levels ?? record.supportedReasoningLevels;
if (!Array.isArray(value)) {
- return [];
+ return undefined;
}
return value.flatMap((entry) => {
if (typeof entry === "string" && entry.trim().length > 0) {
@@ -334,9 +334,16 @@ function normalizeOpenAICodexCatalogModel(
modelId === OPENAI_GPT_56_TERRA_MODEL_ID ||
modelId === OPENAI_GPT_56_LUNA_MODEL_ID
) {
- const supportedReasoningEfforts = model.compat?.supportedReasoningEfforts?.filter(
- (effort) => effort !== "none",
- );
+ const supportsNativeUltra =
+ modelId === OPENAI_GPT_56_SOL_MODEL_ID || modelId === OPENAI_GPT_56_TERRA_MODEL_ID;
+ const supportedReasoningEfforts = model.compat?.supportedReasoningEfforts
+ ? [
+ ...new Set([
+ ...model.compat.supportedReasoningEfforts.filter((effort) => effort !== "none"),
+ ...(supportsNativeUltra ? (["ultra"] as const) : []),
+ ]),
+ ]
+ : undefined;
return {
...model,
contextWindow: OPENAI_CODEX_GPT_56_CONTEXT_TOKENS,
@@ -389,7 +396,7 @@ function buildOpenAICodexModelFromLiveRow(row: unknown): ModelDefinitionConfig |
fallback?.maxTokens ??
OPENAI_GPT_54_MAX_TOKENS;
const compat =
- reasoningLevels.length > 0
+ reasoningLevels !== undefined
? {
...fallback?.compat,
supportsReasoningEffort: true,
@@ -397,10 +404,10 @@ function buildOpenAICodexModelFromLiveRow(row: unknown): ModelDefinitionConfig |
}
: fallback?.compat;
const thinkingLevelMap = {
- ...fallback?.thinkingLevelMap,
+ ...(reasoningLevels === undefined ? fallback?.thinkingLevelMap : {}),
...(normalizeLowercaseStringOrEmpty(modelId).startsWith("gpt-5.6") ? { off: null } : {}),
- ...(reasoningLevels.includes("xhigh") ? { xhigh: "xhigh" as const } : {}),
- ...(reasoningLevels.includes("max") ? { max: "max" as const } : {}),
+ ...(reasoningLevels?.includes("xhigh") ? { xhigh: "xhigh" as const } : {}),
+ ...(reasoningLevels?.includes("max") ? { max: "max" as const } : {}),
};
return {
@@ -408,7 +415,7 @@ function buildOpenAICodexModelFromLiveRow(row: unknown): ModelDefinitionConfig |
name: readCodexModelString(row, "display_name") ?? fallback?.name ?? modelId,
api: "openai-chatgpt-responses",
baseUrl: OPENAI_CODEX_RESPONSES_BASE_URL,
- reasoning: reasoningLevels.length > 0 || fallback?.reasoning || false,
+ reasoning: (reasoningLevels?.length ?? 0) > 0 || fallback?.reasoning || false,
input: resolveCodexModelInput(row, fallback),
cost: fallback?.cost ?? OPENAI_UNKNOWN_MODEL_COST,
contextWindow,
@@ -922,9 +929,9 @@ export function buildOpenAIProvider(): ProviderPlugin {
matchesContextOverflowError: ({ errorMessage }) =>
/content_filter.*(?:prompt|input).*(?:too long|exceed)/i.test(errorMessage),
resolveReasoningOutputMode: () => "native",
- resolveThinkingProfile: ({ provider, modelId }) =>
+ resolveThinkingProfile: ({ provider, modelId, agentRuntime, compat }) =>
normalizeProviderId(provider) === PROVIDER_ID
- ? resolveUnifiedOpenAIThinkingProfile(modelId)
+ ? resolveUnifiedOpenAIThinkingProfile(modelId, agentRuntime, compat)
: null,
isModernModelRef: ({ modelId }) => matchesExactOrPrefix(modelId, OPENAI_MODERN_MODEL_IDS),
augmentModelCatalog: (ctx) => {
diff --git a/extensions/openai/provider-policy-api.test.ts b/extensions/openai/provider-policy-api.test.ts
index 9bae0a4f48fc..35db9603787d 100644
--- a/extensions/openai/provider-policy-api.test.ts
+++ b/extensions/openai/provider-policy-api.test.ts
@@ -42,4 +42,125 @@ describe("OpenAI provider policy artifact", () => {
expect(lunaLevels).toContain("xhigh");
expect(lunaLevels).toContain("max");
});
+
+ it.each([
+ ["gpt-5.6-sol", "codex", "low"],
+ ["gpt-5.6-sol", "openclaw", "low"],
+ ["gpt-5.6-terra", "codex", "medium"],
+ ["gpt-5.6-terra", "openclaw", "medium"],
+ ["gpt-5.6-luna", "codex", "medium"],
+ ["gpt-5.6-luna", "openclaw", "medium"],
+ ])("uses the model default for %s on %s", (modelId, agentRuntime, expected) => {
+ const profile = resolveThinkingProfile({
+ provider: "openai",
+ modelId,
+ agentRuntime,
+ });
+
+ expect(profile?.defaultLevel).toBe(expected);
+ });
+
+ it.each(["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"])(
+ "exposes logical Ultra for %s on the OpenClaw runtime",
+ (modelId) => {
+ const levels = resolveThinkingProfile({
+ provider: "openai",
+ modelId,
+ agentRuntime: "openclaw",
+ })?.levels.map((level) => level.id);
+
+ expect(levels).toContain("ultra");
+ },
+ );
+
+ it.each(["gpt-5.6-sol", "gpt-5.6-terra"])(
+ "uses native Ultra fallback for %s when model/list metadata is unavailable",
+ (modelId) => {
+ const levels = resolveThinkingProfile({
+ provider: "openai",
+ modelId,
+ agentRuntime: "codex",
+ })?.levels.map((level) => level.id);
+
+ expect(levels).toContain("ultra");
+ },
+ );
+
+ it.each(["gpt-5.6-sol", "gpt-5.6-terra"])(
+ "keeps native Ultra fallback for %s with direct OpenAI API metadata",
+ (modelId) => {
+ const levels = resolveThinkingProfile({
+ provider: "openai",
+ modelId,
+ agentRuntime: "codex",
+ compat: {
+ supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
+ },
+ })?.levels.map((level) => level.id);
+
+ expect(levels).toContain("ultra");
+ },
+ );
+
+ it("does not invent native Ultra support for bare or suffixed GPT-5.6 refs", () => {
+ for (const modelId of ["gpt-5.6", "gpt-5.6-sol-oai"]) {
+ const levels = resolveThinkingProfile({
+ provider: "openai",
+ modelId,
+ agentRuntime: "codex",
+ })?.levels.map((level) => level.id);
+
+ expect(levels).not.toContain("max");
+ expect(levels).not.toContain("ultra");
+ }
+ });
+
+ it("lets authoritative Codex model/list metadata override native fallbacks", () => {
+ const solLevels = resolveThinkingProfile({
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ agentRuntime: "codex",
+ compat: { supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max"] },
+ })?.levels.map((level) => level.id);
+ const terraLevels = resolveThinkingProfile({
+ provider: "openai",
+ modelId: "gpt-5.6-terra",
+ agentRuntime: "codex",
+ compat: {
+ supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
+ },
+ })?.levels.map((level) => level.id);
+
+ expect(solLevels).not.toContain("ultra");
+ expect(terraLevels).toContain("ultra");
+ });
+
+ it.each([
+ { efforts: [], expected: ["off"] },
+ { efforts: ["high"], expected: ["off", "high"] },
+ ])("uses the complete authoritative Codex effort list for $efforts", ({ efforts, expected }) => {
+ const profile = resolveThinkingProfile({
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ agentRuntime: "codex",
+ compat: { supportedReasoningEfforts: efforts },
+ });
+
+ expect(profile?.levels.map((level) => level.id)).toEqual(expected);
+ expect(profile?.defaultLevel).toBeUndefined();
+ });
+
+ it("keeps Codex Luna capped at Max without authoritative Ultra metadata", () => {
+ const levels = resolveThinkingProfile({
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ agentRuntime: "codex",
+ compat: {
+ supportedReasoningEfforts: ["none", "low", "medium", "high", "xhigh", "max"],
+ },
+ })?.levels.map((level) => level.id);
+
+ expect(levels).toContain("max");
+ expect(levels).not.toContain("ultra");
+ });
});
diff --git a/extensions/openai/provider-policy-api.ts b/extensions/openai/provider-policy-api.ts
index 43694a9d78cb..084f87df74d9 100644
--- a/extensions/openai/provider-policy-api.ts
+++ b/extensions/openai/provider-policy-api.ts
@@ -1,3 +1,4 @@
+import type { ProviderDefaultThinkingPolicyContext } from "openclaw/plugin-sdk/plugin-entry";
// Openai API module exposes the plugin public contract.
import type { ModelProviderConfig } from "openclaw/plugin-sdk/provider-model-types";
import { resolveUnifiedOpenAIThinkingProfile } from "./thinking-policy.js";
@@ -6,10 +7,14 @@ export function normalizeConfig(params: { provider: string; providerConfig: Mode
return params.providerConfig;
}
-export function resolveThinkingProfile(params: { provider: string; modelId: string }) {
+export function resolveThinkingProfile(params: ProviderDefaultThinkingPolicyContext) {
switch (params.provider.trim().toLowerCase()) {
case "openai":
- return resolveUnifiedOpenAIThinkingProfile(params.modelId);
+ return resolveUnifiedOpenAIThinkingProfile(
+ params.modelId,
+ params.agentRuntime,
+ params.compat,
+ );
default:
return null;
}
diff --git a/extensions/openai/thinking-policy.ts b/extensions/openai/thinking-policy.ts
index f7f36c79419f..6c03f267868d 100644
--- a/extensions/openai/thinking-policy.ts
+++ b/extensions/openai/thinking-policy.ts
@@ -1,5 +1,10 @@
// Openai plugin module implements thinking policy behavior.
-import type { ProviderThinkingProfile } from "openclaw/plugin-sdk/plugin-entry";
+import type {
+ ProviderDefaultThinkingPolicyContext,
+ ProviderThinkingProfile,
+} from "openclaw/plugin-sdk/plugin-entry";
+
+type OpenAIThinkingCompat = ProviderDefaultThinkingPolicyContext["compat"];
const OPENAI_THINKING_BASE_LEVELS = [
{ id: "off" },
@@ -9,6 +14,18 @@ const OPENAI_THINKING_BASE_LEVELS = [
{ id: "high" },
] as const satisfies ProviderThinkingProfile["levels"];
+const OPENAI_THINKING_LEVEL_ORDER = [
+ "off",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+ "ultra",
+] as const;
+type OpenAIThinkingLevelId = (typeof OPENAI_THINKING_LEVEL_ORDER)[number];
+
const OPENAI_CODEX_XHIGH_MODEL_IDS = [
"gpt-5.6",
"gpt-5.5",
@@ -36,26 +53,103 @@ function matchesExactOrPrefix(id: string, values: readonly string[]): boolean {
});
}
+function normalizeCodexReasoningEffort(value: string): OpenAIThinkingLevelId | undefined {
+ const normalized = normalizeModelId(value);
+ if (normalized === "none") {
+ return "off";
+ }
+ return OPENAI_THINKING_LEVEL_ORDER.find((level) => level === normalized);
+}
+
+function buildAuthoritativeCodexLevels(
+ efforts: readonly string[],
+): ProviderThinkingProfile["levels"] {
+ // Omitting an effort remains a valid Codex choice even when model/list has
+ // no reasoning presets. Every other picker stop must come from that list.
+ const supported = new Set(["off"]);
+ for (const effort of efforts) {
+ const level = normalizeCodexReasoningEffort(effort);
+ if (level) {
+ supported.add(level);
+ }
+ }
+ return OPENAI_THINKING_LEVEL_ORDER.filter((level) => supported.has(level)).map((id) => ({ id }));
+}
+
function buildOpenAIThinkingProfile(params: {
modelId: string;
xhighModelIds: readonly string[];
+ agentRuntime?: string | null;
+ compat?: OpenAIThinkingCompat;
}): ProviderThinkingProfile {
- const supportsMax = normalizeModelId(params.modelId).startsWith("gpt-5.6");
+ const modelId = normalizeModelId(params.modelId);
+ const agentRuntime = normalizeModelId(params.agentRuntime ?? "");
+ const isBare = modelId === "gpt-5.6";
+ const isSol = modelId === "gpt-5.6-sol";
+ const isTerra = modelId === "gpt-5.6-terra";
+ const isLuna = modelId === "gpt-5.6-luna";
+ const codexEfforts = params.compat?.supportedReasoningEfforts?.map(normalizeModelId);
+ const hasDirectOpenAICompat = codexEfforts?.includes("none") === true;
+ const authoritativeCodexEfforts = hasDirectOpenAICompat ? undefined : codexEfforts;
+ const fallbackCodexMax = isSol || isTerra || isLuna;
+ const codexSupportsMax = authoritativeCodexEfforts
+ ? authoritativeCodexEfforts.includes("max")
+ : fallbackCodexMax;
+ const supportsMax =
+ modelId.startsWith("gpt-5.6") && (agentRuntime !== "codex" || codexSupportsMax);
+ const fallbackCodexUltra = isSol || isTerra;
+ const codexSupportsUltra = authoritativeCodexEfforts
+ ? authoritativeCodexEfforts.includes("ultra")
+ : fallbackCodexUltra;
+ // OpenClaw owns its logical Ultra orchestration. Native Codex owns its Ultra
+ // catalog; direct API metadata must not erase the known native fallback.
+ const supportsUltra =
+ (isBare || isSol || isTerra || isLuna) &&
+ (agentRuntime === "openclaw" ||
+ agentRuntime === "auto" ||
+ (agentRuntime === "codex" && codexSupportsUltra));
+ const defaultLevel = isSol ? "low" : isTerra || isLuna ? "medium" : undefined;
+ const fallbackLevels: ProviderThinkingProfile["levels"] = [
+ ...OPENAI_THINKING_BASE_LEVELS,
+ ...(matchesExactOrPrefix(params.modelId, params.xhighModelIds)
+ ? [{ id: "xhigh" as const }]
+ : []),
+ ...(supportsMax ? [{ id: "max" as const }] : []),
+ ...(supportsUltra ? [{ id: "ultra" as const }] : []),
+ ];
+ const levels =
+ agentRuntime === "codex" && authoritativeCodexEfforts !== undefined
+ ? buildAuthoritativeCodexLevels(authoritativeCodexEfforts)
+ : fallbackLevels;
+ const supportedDefault = defaultLevel && levels.some((level) => level.id === defaultLevel);
return {
- levels: [
- ...OPENAI_THINKING_BASE_LEVELS,
- ...(matchesExactOrPrefix(params.modelId, params.xhighModelIds)
- ? [{ id: "xhigh" as const }]
- : []),
- ...(supportsMax ? [{ id: "max" as const }] : []),
- ],
+ levels,
+ ...(supportedDefault ? { defaultLevel } : {}),
};
}
-export function resolveOpenAICodexThinkingProfile(modelId: string): ProviderThinkingProfile {
- return buildOpenAIThinkingProfile({ modelId, xhighModelIds: OPENAI_CODEX_XHIGH_MODEL_IDS });
+export function resolveOpenAICodexThinkingProfile(
+ modelId: string,
+ agentRuntime?: string | null,
+ compat?: OpenAIThinkingCompat,
+): ProviderThinkingProfile {
+ return buildOpenAIThinkingProfile({
+ modelId,
+ xhighModelIds: OPENAI_CODEX_XHIGH_MODEL_IDS,
+ agentRuntime,
+ compat,
+ });
}
-export function resolveUnifiedOpenAIThinkingProfile(modelId: string): ProviderThinkingProfile {
- return buildOpenAIThinkingProfile({ modelId, xhighModelIds: OPENAI_UNIFIED_XHIGH_MODEL_IDS });
+export function resolveUnifiedOpenAIThinkingProfile(
+ modelId: string,
+ agentRuntime?: string | null,
+ compat?: OpenAIThinkingCompat,
+): ProviderThinkingProfile {
+ return buildOpenAIThinkingProfile({
+ modelId,
+ xhighModelIds: OPENAI_UNIFIED_XHIGH_MODEL_IDS,
+ agentRuntime,
+ compat,
+ });
}
diff --git a/extensions/slack/src/monitor/slash.test.ts b/extensions/slack/src/monitor/slash.test.ts
index 469a62688e57..5c0dbb4a5fbc 100644
--- a/extensions/slack/src/monitor/slash.test.ts
+++ b/extensions/slack/src/monitor/slash.test.ts
@@ -7,6 +7,20 @@ import {
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import { getSlackSlashMocks, resetSlackSlashMocks } from "./slash.test-harness.js";
+const slashCommandMenuMocks = vi.hoisted(() => ({
+ resolveCommandArgMenu: vi.fn(),
+}));
+
+vi.mock("openclaw/plugin-sdk/agent-runtime", async () => {
+ const actual = await vi.importActual(
+ "openclaw/plugin-sdk/agent-runtime",
+ );
+ return {
+ ...actual,
+ loadModelCatalog: vi.fn(async () => []),
+ };
+});
+
vi.mock("./slash-commands.runtime.js", () => {
const usageCommand = { key: "usage", nativeName: "usage" };
const reportCommand = { key: "report", nativeName: "report" };
@@ -19,6 +33,19 @@ vi.mock("./slash-commands.runtime.js", () => {
const unsafeConfirmCommand = { key: "unsafeconfirm", nativeName: "unsafeconfirm" };
const longConfirmCommand = { key: "longconfirm", nativeName: "longconfirm" };
const statusAliasCommand = { key: "status", nativeName: "status" };
+ const thinkCommand = {
+ key: "think",
+ nativeName: "think",
+ argsMenu: "auto",
+ args: [
+ {
+ name: "level",
+ description: "Thinking level",
+ type: "string",
+ choices: () => ["max"],
+ },
+ ],
+ };
const periodArg = { name: "period", description: "period" };
const baseReportPeriodChoices = [
{ value: "day", label: "day" },
@@ -99,6 +126,9 @@ vi.mock("./slash-commands.runtime.js", () => {
if (normalized === "agentstatus") {
return statusAliasCommand;
}
+ if (normalized === "think") {
+ return thinkCommand;
+ }
return undefined;
},
listNativeCommandSpecsForConfig: () => [
@@ -168,12 +198,26 @@ vi.mock("./slash-commands.runtime.js", () => {
acceptsArgs: false,
args: [],
},
+ {
+ name: "think",
+ description: "Thinking",
+ acceptsArgs: true,
+ args: thinkCommand.args,
+ },
],
parseCommandArgs: () => ({ values: {} }),
resolveCommandArgMenu: (params: {
command?: { key?: string };
args?: { values?: unknown };
+ agentRuntime?: string;
}) => {
+ slashCommandMenuMocks.resolveCommandArgMenu(params);
+ if (params.command?.key === "think") {
+ return {
+ arg: thinkCommand.args[0]!,
+ choices: [{ value: "max", label: "max" }],
+ };
+ }
if (params.command?.key === "report") {
return resolvePeriodMenu(params, [
...fullReportPeriodChoices,
@@ -272,6 +316,7 @@ const { dispatchMock } = getSlackSlashMocks();
beforeEach(() => {
clearRuntimeConfigSnapshot();
resetSlackSlashMocks();
+ slashCommandMenuMocks.resolveCommandArgMenu.mockClear();
});
afterEach(() => {
@@ -313,7 +358,9 @@ function createDeferred() {
return { promise, resolve };
}
-function createArgMenusHarness() {
+function createArgMenusHarness(
+ cfg: OpenClawConfig = { commands: { native: true, nativeSkills: false } },
+) {
const commands = new Map Promise>();
const actions = new Map Promise>();
const options = new Map Promise>();
@@ -335,7 +382,7 @@ function createArgMenusHarness() {
};
const ctx = {
- cfg: { commands: { native: true, nativeSkills: false } },
+ cfg,
runtime: {},
botToken: "bot-token",
botUserId: "bot",
@@ -600,6 +647,32 @@ describe("Slack native command argument menus", () => {
expect(testHarness.optionsReceiverContexts[0]).toBe(testHarness.app);
});
+ it.each(["codex", "openclaw"] as const)(
+ "passes the configured %s runtime to dynamic /think choices",
+ async (agentRuntime) => {
+ const testHarness = createArgMenusHarness({
+ commands: { native: true, nativeSkills: false },
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.6-luna" },
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: agentRuntime } },
+ },
+ },
+ },
+ });
+ await registerCommands(testHarness.ctx, testHarness.account);
+ const handler = requireHandler(testHarness.commands, "/think", "/think");
+
+ await runCommandHandler(handler);
+
+ const menuCall = slashCommandMenuMocks.resolveCommandArgMenu.mock.calls.find(
+ ([params]) => (params as { command?: { key?: string } }).command?.key === "think",
+ )?.[0] as { agentRuntime?: string } | undefined;
+ expect(menuCall?.agentRuntime).toBe(agentRuntime);
+ },
+ );
+
it("falls back to static menus when app.options() throws during registration", async () => {
const commands = new Map Promise>();
const actions = new Map Promise>();
diff --git a/extensions/slack/src/monitor/slash.ts b/extensions/slack/src/monitor/slash.ts
index 8bef43f5fa25..b5aa10c034de 100644
--- a/extensions/slack/src/monitor/slash.ts
+++ b/extensions/slack/src/monitor/slash.ts
@@ -4,6 +4,7 @@ import { loadModelCatalog, resolveDefaultModelForAgent } from "openclaw/plugin-s
import { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
import {
formatCommandArgMenuTitle,
+ resolveEffectiveAgentRuntime,
resolveStoredModelOverride,
type ChatCommandDefinition,
} from "openclaw/plugin-sdk/command-auth-native";
@@ -89,7 +90,7 @@ function resolveSlackCommandMenuModelContext(params: {
cfg: SlackMonitorContext["cfg"];
agentId: string;
sessionKey: string;
-}): { provider?: string; model?: string } {
+}): { provider?: string; model?: string; agentRuntime?: string } {
if (!params.sessionKey.trim()) {
return {};
}
@@ -100,29 +101,37 @@ function resolveSlackCommandMenuModelContext(params: {
});
const storePath = resolveStorePath(params.cfg.session?.store, { agentId: params.agentId });
const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey });
+ let provider: string | undefined;
+ let model: string | undefined;
if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) {
- return { provider: defaultModel.provider, model: defaultModel.model };
+ provider = defaultModel.provider;
+ model = defaultModel.model;
+ } else {
+ const override = resolveStoredModelOverride({
+ sessionEntry: entry,
+ loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }),
+ sessionKey: params.sessionKey,
+ defaultProvider: defaultModel.provider,
+ });
+ provider = override?.model
+ ? override.provider || defaultModel.provider
+ : (normalizeOptionalString(entry?.providerOverride) ??
+ normalizeOptionalString(entry?.modelProvider));
+ model = override?.model
+ ? override.model
+ : (normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model));
}
- const override = resolveStoredModelOverride({
- sessionEntry: entry,
- loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }),
- sessionKey: params.sessionKey,
- defaultProvider: defaultModel.provider,
- });
- if (override?.model) {
- return {
- provider: override.provider || defaultModel.provider,
- model: override.model,
- };
- }
- const provider =
- normalizeOptionalString(entry?.providerOverride) ??
- normalizeOptionalString(entry?.modelProvider);
- const model =
- normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model);
return {
...(provider ? { provider } : {}),
...(model ? { model } : {}),
+ agentRuntime: resolveEffectiveAgentRuntime({
+ cfg: params.cfg,
+ provider: provider ?? defaultModel.provider,
+ modelId: model ?? defaultModel.model,
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ sessionEntry: entry,
+ }),
};
} catch {
return {};
diff --git a/extensions/telegram/src/bot-native-commands.session-meta.test.ts b/extensions/telegram/src/bot-native-commands.session-meta.test.ts
index 122f95611ac8..7e3a0eb9414e 100644
--- a/extensions/telegram/src/bot-native-commands.session-meta.test.ts
+++ b/extensions/telegram/src/bot-native-commands.session-meta.test.ts
@@ -770,6 +770,53 @@ describe("registerTelegramNativeCommands — session metadata", () => {
expect(replyMocks.dispatchReplyWithBufferedBlockDispatcher).not.toHaveBeenCalled();
});
+ it.each([
+ { sessionRuntime: undefined, expectedRuntime: "codex" },
+ { sessionRuntime: "openclaw", expectedRuntime: "openclaw" },
+ ])(
+ "uses the effective $expectedRuntime runtime for native /think menus",
+ async ({ sessionRuntime, expectedRuntime }) => {
+ const cfg = {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ } as OpenClawConfig;
+ sessionMocks.loadSessionStore.mockReturnValue({
+ "agent:main:main": {
+ providerOverride: "openai",
+ modelOverride: "gpt-5.6-luna",
+ modelOverrideSource: "user",
+ ...(sessionRuntime ? { agentRuntimeOverride: sessionRuntime } : {}),
+ updatedAt: 0,
+ },
+ });
+
+ const { handler } = registerAndResolveCommandHandler({
+ commandName: "think",
+ cfg,
+ allowFrom: ["*"],
+ });
+ await handler(createTelegramPrivateCommandContext());
+
+ const menuCall = commandAuthMocks.resolveCommandArgMenu.mock.calls.find(
+ ([params]) => params.command.key === "think" && params.model === "gpt-5.6-luna",
+ )?.[0];
+ expectRecordFields(
+ menuCall,
+ {
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: expectedRuntime,
+ },
+ "runtime-aware thinking menu call",
+ );
+ },
+ );
+
it("resolves /think menu choices against the runtime catalog for live-discovered models", async () => {
const cfg = {
agents: { defaults: { models: { "ollama/*": {} } } },
diff --git a/extensions/telegram/src/bot-native-commands.ts b/extensions/telegram/src/bot-native-commands.ts
index a68c32144318..c89e1f010833 100644
--- a/extensions/telegram/src/bot-native-commands.ts
+++ b/extensions/telegram/src/bot-native-commands.ts
@@ -17,6 +17,7 @@ import {
listNativeCommandSpecs,
listNativeCommandSpecsForConfig,
parseCommandArgs,
+ resolveEffectiveAgentRuntime,
resolveCommandArgMenu,
resolveFastModeState,
resolveStoredModelOverride,
@@ -181,17 +182,20 @@ function buildTelegramCodexLoginFlowKey(params: {
].join(":");
}
+type TelegramCommandMenuModelContext = {
+ provider?: string;
+ model?: string;
+ agentRuntime?: string;
+ thinkingLevel?: string;
+ fastMode?: SessionEntry["fastMode"];
+};
+
function buildTelegramCommandMenuModelContext(params: {
provider: string;
model: string;
thinkingLevel?: string;
fastMode?: SessionEntry["fastMode"];
-}): {
- provider: string;
- model: string;
- thinkingLevel?: string;
- fastMode?: SessionEntry["fastMode"];
-} {
+}): TelegramCommandMenuModelContext {
return {
provider: params.provider,
model: params.model,
@@ -264,12 +268,7 @@ function resolveTelegramCommandMenuModelContext(params: {
cfg: OpenClawConfig;
agentId: string;
sessionKey: string;
-}): {
- provider?: string;
- model?: string;
- thinkingLevel?: string;
- fastMode?: SessionEntry["fastMode"];
-} {
+}): TelegramCommandMenuModelContext {
if (!params.sessionKey.trim()) {
return {};
}
@@ -282,38 +281,52 @@ function resolveTelegramCommandMenuModelContext(params: {
const entry = getSessionEntry({ storePath, sessionKey: params.sessionKey });
const thinkingLevel = normalizeOptionalString(entry?.thinkingLevel);
const fastMode = entry?.fastMode;
+ let context: TelegramCommandMenuModelContext;
if (entry?.modelOverrideSource === "auto" && normalizeOptionalString(entry.modelOverride)) {
- return buildTelegramCommandMenuModelContext({
+ context = buildTelegramCommandMenuModelContext({
provider: defaultModel.provider,
model: defaultModel.model,
...(thinkingLevel ? { thinkingLevel } : {}),
...(fastMode !== undefined ? { fastMode } : {}),
});
- }
- const override = resolveStoredModelOverride({
- sessionEntry: entry,
- loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }),
- sessionKey: params.sessionKey,
- defaultProvider: defaultModel.provider,
- });
- if (override?.model) {
- return buildTelegramCommandMenuModelContext({
- provider: override.provider || defaultModel.provider,
- model: override.model,
- ...(thinkingLevel ? { thinkingLevel } : {}),
- ...(fastMode !== undefined ? { fastMode } : {}),
+ } else {
+ const override = resolveStoredModelOverride({
+ sessionEntry: entry,
+ loadSessionEntry: (sessionKey) => getSessionEntry({ storePath, sessionKey }),
+ sessionKey: params.sessionKey,
+ defaultProvider: defaultModel.provider,
});
+ if (override?.model) {
+ context = buildTelegramCommandMenuModelContext({
+ provider: override.provider || defaultModel.provider,
+ model: override.model,
+ ...(thinkingLevel ? { thinkingLevel } : {}),
+ ...(fastMode !== undefined ? { fastMode } : {}),
+ });
+ } else {
+ const provider =
+ normalizeOptionalString(entry?.providerOverride) ??
+ normalizeOptionalString(entry?.modelProvider);
+ const model =
+ normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model);
+ context = {
+ ...(provider ? { provider } : {}),
+ ...(model ? { model } : {}),
+ ...(thinkingLevel ? { thinkingLevel } : {}),
+ ...(fastMode !== undefined ? { fastMode } : {}),
+ };
+ }
}
- const provider =
- normalizeOptionalString(entry?.providerOverride) ??
- normalizeOptionalString(entry?.modelProvider);
- const model =
- normalizeOptionalString(entry?.modelOverride) ?? normalizeOptionalString(entry?.model);
return {
- ...(provider ? { provider } : {}),
- ...(model ? { model } : {}),
- ...(thinkingLevel ? { thinkingLevel } : {}),
- ...(fastMode !== undefined ? { fastMode } : {}),
+ ...context,
+ agentRuntime: resolveEffectiveAgentRuntime({
+ cfg: params.cfg,
+ provider: context.provider ?? defaultModel.provider,
+ modelId: context.model ?? defaultModel.model,
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ sessionEntry: entry,
+ }),
};
} catch {
return {};
@@ -405,6 +418,7 @@ async function resolveTelegramThinkMenuCurrentLevel(params: {
agentId: string;
provider?: string;
model?: string;
+ agentRuntime?: string;
thinkingLevel?: string;
catalog: Awaited>;
}): Promise {
@@ -426,6 +440,7 @@ async function resolveTelegramThinkMenuCurrentLevel(params: {
cfg: params.cfg,
provider: params.provider ?? defaultModel.provider,
model: params.model ?? defaultModel.model,
+ agentRuntime: params.agentRuntime,
loadModelCatalog: async () => params.catalog,
});
}
diff --git a/extensions/voice-call/openclaw.plugin.json b/extensions/voice-call/openclaw.plugin.json
index b58c9e72ba8d..8655c3cd24f4 100644
--- a/extensions/voice-call/openclaw.plugin.json
+++ b/extensions/voice-call/openclaw.plugin.json
@@ -534,7 +534,7 @@
},
"consultThinkingLevel": {
"type": "string",
- "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"]
+ "enum": ["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max", "ultra"]
},
"consultFastMode": {
"type": "boolean"
diff --git a/extensions/voice-call/src/config.test.ts b/extensions/voice-call/src/config.test.ts
index 0f3d704b1908..e8744a8e4ee9 100644
--- a/extensions/voice-call/src/config.test.ts
+++ b/extensions/voice-call/src/config.test.ts
@@ -707,12 +707,12 @@ describe("resolveVoiceCallConfig realtime settings", () => {
enabled: true,
provider: "mock",
realtime: {
- consultThinkingLevel: "low",
+ consultThinkingLevel: "ultra",
consultFastMode: true,
},
});
- expect(resolved.realtime.consultThinkingLevel).toBe("low");
+ expect(resolved.realtime.consultThinkingLevel).toBe("ultra");
expect(resolved.realtime.consultFastMode).toBe(true);
});
diff --git a/extensions/voice-call/src/config.ts b/extensions/voice-call/src/config.ts
index 561a7ac23837..308262f76418 100644
--- a/extensions/voice-call/src/config.ts
+++ b/extensions/voice-call/src/config.ts
@@ -299,6 +299,7 @@ const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
"xhigh",
"adaptive",
"max",
+ "ultra",
]);
const VoiceCallStreamingProvidersConfigSchema = z
diff --git a/extensions/voice-call/src/runtime.test.ts b/extensions/voice-call/src/runtime.test.ts
index 6c67f82acee2..4b1df7103385 100644
--- a/extensions/voice-call/src/runtime.test.ts
+++ b/extensions/voice-call/src/runtime.test.ts
@@ -668,7 +668,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
const config = createBaseConfig();
config.inboundPolicy = "allowlist";
config.realtime.enabled = true;
- config.realtime.consultThinkingLevel = "low";
+ config.realtime.consultThinkingLevel = "ultra";
config.realtime.consultFastMode = true;
const sessionStore: Record = {};
const runEmbeddedAgent = vi.fn(async () => ({
@@ -714,7 +714,7 @@ describe("createVoiceCallRuntime lifecycle", () => {
),
"configured embedded OpenClaw consult params",
);
- expect(consultParams.thinkLevel).toBe("low");
+ expect(consultParams.thinkLevel).toBe("ultra");
expect(consultParams.fastMode).toBe(true);
});
});
diff --git a/packages/gateway-protocol/src/index.ts b/packages/gateway-protocol/src/index.ts
index 2f8f6ef52957..4385382c755c 100644
--- a/packages/gateway-protocol/src/index.ts
+++ b/packages/gateway-protocol/src/index.ts
@@ -1829,6 +1829,8 @@ type SessionsPatchResult = {
modelProvider?: string;
model?: string;
agentRuntime?: GatewayAgentRuntime;
+ thinkingLevel?: string;
+ thinkingLevels?: Array<{ id: string; label: string }>;
};
};
diff --git a/scripts/plugin-sdk-surface-report.mjs b/scripts/plugin-sdk-surface-report.mjs
index a135149779a1..98acb478493a 100644
--- a/scripts/plugin-sdk-surface-report.mjs
+++ b/scripts/plugin-sdk-surface-report.mjs
@@ -101,7 +101,7 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"runtime-logger": 3,
"runtime-secret-resolution": 5,
"setup-adapter-runtime": 1,
- "channel-streaming": 48,
+ "channel-streaming": 49,
"approval-reply-runtime": 1,
"config-runtime": 123,
"config-contracts": 1,
@@ -147,8 +147,8 @@ const defaultPublicDeprecatedExportsByEntrypointBudget = Object.freeze({
"channel-mention-gating": 7,
"channel-lifecycle": 23,
"channel-ingress": 8,
- "channel-message": 229,
- "channel-message-runtime": 226,
+ "channel-message": 230,
+ "channel-message-runtime": 227,
"channel-pairing-paths": 1,
"channel-policy": 8,
"channel-route": 5,
@@ -195,17 +195,17 @@ export function readPluginSdkSurfaceBudgets(env = process.env) {
),
publicExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_EXPORTS",
- 10488,
+ 10490,
env,
),
publicFunctionExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_FUNCTION_EXPORTS",
- 5235,
+ 5236,
env,
),
publicDeprecatedExports: readPluginSdkSurfaceBudgetEnv(
"OPENCLAW_PLUGIN_SDK_MAX_PUBLIC_DEPRECATED_EXPORTS",
- 3265,
+ 3269,
env,
),
publicWildcardReexports: readPluginSdkSurfaceBudgetEnv(
diff --git a/scripts/test-live-codex-harness-docker.sh b/scripts/test-live-codex-harness-docker.sh
index de2fb453e376..252a78f5f8ed 100644
--- a/scripts/test-live-codex-harness-docker.sh
+++ b/scripts/test-live-codex-harness-docker.sh
@@ -19,6 +19,14 @@ PROFILE_FILE="$(openclaw_live_default_profile_file)"
CODEX_HARNESS_AUTH_MODE="${OPENCLAW_LIVE_CODEX_HARNESS_AUTH:-codex-auth}"
CODEX_CLI_PACKAGE_SPEC="${OPENCLAW_LIVE_CODEX_CLI_PACKAGE_SPEC:-}"
CODEX_HARNESS_SETUP_TIMEOUT_SECONDS="$(openclaw_live_read_positive_int_env OPENCLAW_LIVE_CODEX_HARNESS_SETUP_TIMEOUT_SECONDS 180)"
+CODEX_HARNESS_TARGET_COUNT=1
+if [[ -n "${OPENCLAW_LIVE_CODEX_HARNESS_TARGETS:-}" ]]; then
+ IFS=',' read -r -a CODEX_HARNESS_TARGET_ITEMS <<<"$OPENCLAW_LIVE_CODEX_HARNESS_TARGETS"
+ CODEX_HARNESS_TARGET_COUNT="${#CODEX_HARNESS_TARGET_ITEMS[@]}"
+fi
+# Each target starts an isolated 15-minute Vitest suite. Preserve the old
+# 35-minute single-target budget while scaling matrix runs linearly.
+CODEX_HARNESS_DOCKER_RUN_TIMEOUT="${OPENCLAW_LIVE_CODEX_HARNESS_DOCKER_RUN_TIMEOUT:-$((2100 * CODEX_HARNESS_TARGET_COUNT))s}"
TEMP_DIRS=()
DOCKER_USER="${OPENCLAW_DOCKER_USER:-node}"
DOCKER_HOME_MOUNT=()
@@ -304,7 +312,30 @@ if ! "$NPM_CONFIG_PREFIX/bin/codex" exec \
tail -c 262144 "$codex_preflight_log" >&2 || true
exit 1
fi
-node scripts/test-live.mjs -- ${OPENCLAW_LIVE_CODEX_TEST_FILES:-src/gateway/gateway-codex-harness.live.test.ts}
+run_codex_harness_target() {
+ local model="${1:?model required}"
+ local thinking="${2:?thinking required}"
+ export OPENCLAW_LIVE_CODEX_HARNESS_MODEL="$model"
+ export OPENCLAW_LIVE_CODEX_HARNESS_THINKING="$thinking"
+ echo "==> Codex harness target: model=$model thinking=$thinking"
+ node scripts/test-live.mjs -- ${OPENCLAW_LIVE_CODEX_TEST_FILES:-src/gateway/gateway-codex-harness.live.test.ts}
+}
+if [ -n "${OPENCLAW_LIVE_CODEX_HARNESS_TARGETS:-}" ]; then
+ IFS=',' read -r -a harness_targets <<<"$OPENCLAW_LIVE_CODEX_HARNESS_TARGETS"
+ for harness_target in "${harness_targets[@]}"; do
+ model="${harness_target%%=*}"
+ thinking="${harness_target##*=}"
+ if [ -z "$model" ] || [ -z "$thinking" ] || [ "$model" = "$thinking" ]; then
+ echo "ERROR: invalid Codex harness target '$harness_target'; expected provider/model=thinking." >&2
+ exit 1
+ fi
+ run_codex_harness_target "$model" "$thinking"
+ done
+else
+ run_codex_harness_target \
+ "${OPENCLAW_LIVE_CODEX_HARNESS_MODEL:-codex/gpt-5.5}" \
+ "${OPENCLAW_LIVE_CODEX_HARNESS_THINKING:-low}"
+fi
EOF
openclaw_live_codex_harness_append_build_extension codex
@@ -327,6 +358,10 @@ fi
echo "==> Run Codex harness live test in Docker"
echo "==> Model: ${OPENCLAW_LIVE_CODEX_HARNESS_MODEL:-codex/gpt-5.5}"
+echo "==> Thinking: ${OPENCLAW_LIVE_CODEX_HARNESS_THINKING:-low}"
+echo "==> Targets: ${OPENCLAW_LIVE_CODEX_HARNESS_TARGETS:-single model}"
+echo "==> Target count: $CODEX_HARNESS_TARGET_COUNT"
+echo "==> Docker run timeout: $CODEX_HARNESS_DOCKER_RUN_TIMEOUT"
echo "==> Chat image probe: ${OPENCLAW_LIVE_CODEX_HARNESS_CHAT_IMAGE_PROBE:-0}"
echo "==> Image probe: ${OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE:-1}"
echo "==> MCP probe: ${OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE:-1}"
@@ -341,7 +376,7 @@ echo "==> Codex CLI package: $CODEX_CLI_PACKAGE_SPEC"
echo "==> Harness fallback: none"
echo "==> Auth files: ${AUTH_FILES_CSV:-none}"
DOCKER_RUN_ARGS=()
-openclaw_live_init_docker_run_args DOCKER_RUN_ARGS "${OPENCLAW_LIVE_CODEX_HARNESS_DOCKER_RUN_TIMEOUT:-2100s}"
+openclaw_live_init_docker_run_args DOCKER_RUN_ARGS "$CODEX_HARNESS_DOCKER_RUN_TIMEOUT"
DOCKER_RUN_ARGS+=(--rm -t \
-u "$DOCKER_USER" \
--entrypoint bash \
@@ -367,6 +402,8 @@ DOCKER_RUN_ARGS+=(--rm -t \
-e OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE="${OPENCLAW_LIVE_CODEX_HARNESS_IMAGE_PROBE:-1}" \
-e OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE="${OPENCLAW_LIVE_CODEX_HARNESS_MCP_PROBE:-1}" \
-e OPENCLAW_LIVE_CODEX_HARNESS_MODEL="${OPENCLAW_LIVE_CODEX_HARNESS_MODEL:-codex/gpt-5.5}" \
+ -e OPENCLAW_LIVE_CODEX_HARNESS_TARGETS="${OPENCLAW_LIVE_CODEX_HARNESS_TARGETS:-}" \
+ -e OPENCLAW_LIVE_CODEX_HARNESS_THINKING="${OPENCLAW_LIVE_CODEX_HARNESS_THINKING:-low}" \
-e OPENCLAW_LIVE_CODEX_HARNESS_REQUIRE_GUARDIAN_EVENTS="${OPENCLAW_LIVE_CODEX_HARNESS_REQUIRE_GUARDIAN_EVENTS:-1}" \
-e OPENCLAW_LIVE_CODEX_HARNESS_REQUEST_TIMEOUT_MS="${OPENCLAW_LIVE_CODEX_HARNESS_REQUEST_TIMEOUT_MS:-}" \
-e OPENCLAW_LIVE_CODEX_HARNESS_SETUP_TIMEOUT_SECONDS="$CODEX_HARNESS_SETUP_TIMEOUT_SECONDS" \
diff --git a/src/acp/commands.test.ts b/src/acp/commands.test.ts
new file mode 100644
index 000000000000..3131d28d57bd
--- /dev/null
+++ b/src/acp/commands.test.ts
@@ -0,0 +1,10 @@
+import { describe, expect, it } from "vitest";
+import { getAvailableCommands } from "./commands.js";
+
+describe("ACP available commands", () => {
+ it("advertises Ultra in the thinking command", () => {
+ const think = getAvailableCommands().find((command) => command.name === "think");
+
+ expect(think?.description).toContain("off|minimal|low|medium|high|xhigh|adaptive|max|ultra");
+ });
+});
diff --git a/src/acp/commands.ts b/src/acp/commands.ts
index 774b936db459..270f36294bd0 100644
--- a/src/acp/commands.ts
+++ b/src/acp/commands.ts
@@ -1,6 +1,7 @@
/** Builds the ACP available-command list exposed to compatible clients. */
import type { AvailableCommand } from "@agentclientprotocol/sdk";
import { getChatCommands } from "../auto-reply/commands-registry.data.js";
+import { THINKING_LEVELS_HELP } from "../auto-reply/thinking.shared.js";
const BASE_AVAILABLE_COMMANDS: AvailableCommand[] = [
{ name: "help", description: "Show help and common commands." },
@@ -16,7 +17,11 @@ const BASE_AVAILABLE_COMMANDS: AvailableCommand[] = [
{ name: "subagents", description: "List or manage sub-agents." },
{ name: "config", description: "Read or write config (owner-only)." },
{ name: "debug", description: "Set runtime-only overrides (owner-only)." },
- { name: "usage", description: "Toggle usage footer (off|tokens|full|reset). 'reset'/'inherit'/'clear'/'default' clears the session override to re-inherit the configured default." },
+ {
+ name: "usage",
+ description:
+ "Toggle usage footer (off|tokens|full|reset). 'reset'/'inherit'/'clear'/'default' clears the session override to re-inherit the configured default.",
+ },
{ name: "stop", description: "Stop the current run." },
{ name: "restart", description: "Restart the gateway (if enabled)." },
{ name: "activation", description: "Set group activation (mention|always)." },
@@ -25,7 +30,7 @@ const BASE_AVAILABLE_COMMANDS: AvailableCommand[] = [
{ name: "new", description: "Reset the session (/reset)." },
{
name: "think",
- description: "Set thinking level (off|minimal|low|medium|high|xhigh).",
+ description: `Set thinking level (${THINKING_LEVELS_HELP}).`,
},
{ name: "verbose", description: "Set verbose mode (on|full|off)." },
{ name: "trace", description: "Set plugin trace mode (on|off)." },
diff --git a/src/agents/agent-command.live-model-switch.test.ts b/src/agents/agent-command.live-model-switch.test.ts
index 08fb1885a1d7..325a6d572a64 100644
--- a/src/agents/agent-command.live-model-switch.test.ts
+++ b/src/agents/agent-command.live-model-switch.test.ts
@@ -846,6 +846,7 @@ type FallbackRunnerParams = {
provider: string;
model: string;
sessionId?: string;
+ resolveAgentHarnessRuntimeOverride?: (provider: string, model: string) => string | undefined;
run: (provider: string, model: string) => Promise;
onFallbackStep?: (step: Record) => void | Promise;
classifyResult?: (params: {
@@ -1179,6 +1180,33 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
);
});
+ it("retries a same-model switch with the runtime carried by the error", async () => {
+ const sessionEntry: SessionEntry = {
+ sessionId: "session-1",
+ updatedAt: 1,
+ agentRuntimeOverride: "openclaw",
+ };
+ state.sessionEntryMock = sessionEntry;
+ state.sessionStoreMock = { "agent:main:main": sessionEntry };
+ state.storePathMock = "/tmp/openclaw-sessions.json";
+ setupModelSwitchRetry({
+ provider: "openai",
+ model: "gpt-5.4",
+ agentRuntimeOverride: "codex",
+ });
+ state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
+
+ await runBasicAgentCommand();
+
+ const retry = mockCallArg(state.runWithModelFallbackMock, 1) as FallbackRunnerParams;
+ expect(retry.resolveAgentHarnessRuntimeOverride?.("openai", "gpt-5.4")).toBe("codex");
+ expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
+ providerOverride: "openai",
+ modelOverride: "gpt-5.4",
+ agentHarnessRuntimeOverride: "codex",
+ });
+ });
+
it("keeps the fast mode cutoff timestamp across live model switch retries", async () => {
let invocation = 0;
state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
@@ -1549,15 +1577,32 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("anthropic", "claude-fable-5"));
state.resolvedSessionKeyMock = "agent:main:main";
state.isThinkingLevelSupportedMock.mockReturnValue(false);
+ const sessionEntry: SessionEntry = {
+ sessionId: "session-1",
+ updatedAt: 1,
+ skillsSnapshot: { prompt: "", skills: [], version: 0 },
+ thinkingLevel: "low",
+ };
+ state.sessionEntryMock = sessionEntry;
+ state.sessionStoreMock = { "agent:main:main": sessionEntry };
+ state.storePathMock = "/tmp/openclaw-sessions.json";
await expect(
agentCommand({
message: "hello",
to: "+1234567890",
- thinking: "xhigh",
+ thinking: "ultra",
}),
).rejects.toThrow(/is not supported/u);
expect(state.runAgentAttemptMock).not.toHaveBeenCalled();
+ expect(
+ (state.sessionStoreMock as Record)["agent:main:main"]?.thinkingLevel,
+ ).toBe("low");
+ expect(state.persistSessionEntryMock).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ entry: expect.objectContaining({ thinkingLevel: "ultra" }),
+ }),
+ );
});
it("skips the initial session touch after gateway ingress already persisted activity", async () => {
@@ -2114,6 +2159,41 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
expect(state.updateSessionStoreAfterAgentRunMock).toHaveBeenCalledTimes(1);
});
+ it("forwards an explicit OpenClaw runtime override into fallback and attempt execution", async () => {
+ setupSingleAttemptFallback();
+ state.runtimeConfigMock = {
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.4" },
+ models: { "openai/gpt-5.4": {} },
+ },
+ },
+ };
+ const sessionEntry: SessionEntry = {
+ sessionId: "session-1",
+ updatedAt: 1,
+ skillsSnapshot: { prompt: "", skills: [], version: 0 },
+ agentRuntimeOverride: "openclaw",
+ agentHarnessId: "codex",
+ };
+ state.sessionEntryMock = sessionEntry;
+ state.sessionStoreMock = { "agent:main:main": sessionEntry };
+ state.storePathMock = "/tmp/openclaw-sessions.json";
+ state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
+
+ await runBasicAgentCommand();
+
+ const fallbackParams = mockCallArg(state.runWithModelFallbackMock) as FallbackRunnerParams;
+ expect(fallbackParams.resolveAgentHarnessRuntimeOverride?.("openai", "gpt-5.4")).toBe(
+ "openclaw",
+ );
+ expectRecordFields(mockCallArg(state.runAgentAttemptMock), {
+ providerOverride: "openai",
+ modelOverride: "gpt-5.4",
+ agentHarnessRuntimeOverride: "openclaw",
+ });
+ });
+
it("does not persist turn-local thinking fallback over a stored session override", async () => {
setupSingleAttemptFallback();
const sessionEntry: SessionEntry = {
@@ -2144,6 +2224,127 @@ describe("agentCommand – LiveSessionModelSwitchError retry", () => {
);
});
+ it("revalidates immutable Ultra for each model fallback without persisting the remap", async () => {
+ const sessionEntry: SessionEntry = {
+ sessionId: "session-1",
+ updatedAt: 1,
+ skillsSnapshot: { prompt: "", skills: [], version: 0 },
+ thinkingLevel: "ultra",
+ };
+ state.sessionEntryMock = sessionEntry;
+ state.sessionStoreMock = { "agent:main:main": sessionEntry };
+ state.storePathMock = "/tmp/openclaw-sessions.json";
+ state.runtimeConfigMock = {
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.6-luna" },
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ };
+ state.isThinkingLevelSupportedMock.mockImplementation((args: unknown) => {
+ const { model, level } = args as { model?: string; level?: string };
+ return model !== "gpt-5.6-luna" || level !== "ultra";
+ });
+ state.resolveSupportedThinkingLevelMock.mockImplementation(
+ ({ level, model }: { level?: string; model?: string }) =>
+ model === "gpt-5.6-luna" && level === "ultra" ? "max" : level,
+ );
+ state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
+ await params.run(params.provider, params.model);
+ const result = await params.run("openai", "gpt-5.6-sol");
+ return {
+ result,
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ attempts: [],
+ };
+ });
+ state.runAgentAttemptMock.mockImplementation(
+ async (params: { providerOverride: string; modelOverride: string }) =>
+ makeSuccessResult(params.providerOverride, params.modelOverride),
+ );
+
+ await runBasicAgentCommand();
+
+ expectRecordFields(mockCallArg(state.runAgentAttemptMock, 0), {
+ modelOverride: "gpt-5.6-luna",
+ resolvedThinkLevel: "max",
+ });
+ expectRecordFields(mockCallArg(state.runAgentAttemptMock, 1), {
+ modelOverride: "gpt-5.6-sol",
+ resolvedThinkLevel: "ultra",
+ });
+ expect(state.resolveSupportedThinkingLevelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ level: "ultra",
+ agentRuntime: "codex",
+ }),
+ );
+ expect(sessionEntry.thinkingLevel).toBe("ultra");
+ expect(state.persistSessionEntryMock).not.toHaveBeenCalledWith(
+ expect.objectContaining({
+ entry: expect.objectContaining({ thinkingLevel: "max" }),
+ }),
+ );
+ });
+
+ it("recomputes a model-derived thinking default for each fallback candidate", async () => {
+ const sessionEntry: SessionEntry = {
+ sessionId: "session-1",
+ updatedAt: 1,
+ skillsSnapshot: { prompt: "", skills: [], version: 0 },
+ };
+ state.sessionEntryMock = sessionEntry;
+ state.sessionStoreMock = { "agent:main:main": sessionEntry };
+ state.storePathMock = "/tmp/openclaw-sessions.json";
+ state.runtimeConfigMock = {
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.6-sol" },
+ models: {
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
+ "openai/gpt-5.6-terra": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ };
+ state.resolveThinkingDefaultMock.mockImplementation((args: unknown) => {
+ const { model } = args as { model?: string };
+ return model === "gpt-5.6-terra" ? "medium" : "low";
+ });
+ state.runWithModelFallbackMock.mockImplementation(async (params: FallbackRunnerParams) => {
+ await params.run(params.provider, params.model);
+ const result = await params.run("openai", "gpt-5.6-terra");
+ return {
+ result,
+ provider: "openai",
+ model: "gpt-5.6-terra",
+ attempts: [],
+ };
+ });
+ state.runAgentAttemptMock.mockImplementation(
+ async (params: { providerOverride: string; modelOverride: string }) =>
+ makeSuccessResult(params.providerOverride, params.modelOverride),
+ );
+
+ await runBasicAgentCommand();
+
+ expectRecordFields(mockCallArg(state.runAgentAttemptMock, 0), {
+ modelOverride: "gpt-5.6-sol",
+ resolvedThinkLevel: "low",
+ });
+ expectRecordFields(mockCallArg(state.runAgentAttemptMock, 1), {
+ modelOverride: "gpt-5.6-terra",
+ resolvedThinkLevel: "medium",
+ });
+ });
+
it("persists and clears current run delivery context for restart recovery", async () => {
setupSingleAttemptFallback();
state.runAgentAttemptMock.mockResolvedValue(makeSuccessResult("openai", "gpt-5.4"));
diff --git a/src/agents/agent-command.ts b/src/agents/agent-command.ts
index c3584a87f67b..65ff3894bd61 100644
--- a/src/agents/agent-command.ts
+++ b/src/agents/agent-command.ts
@@ -8,7 +8,6 @@ import {
isThinkingLevelSupported,
normalizeThinkLevel,
normalizeVerboseLevel,
- resolveSupportedThinkingLevel,
type VerboseLevel,
} from "../auto-reply/thinking.js";
import { resolveChannelModelOverride } from "../channels/model-overrides.js";
@@ -157,7 +156,9 @@ import {
resolveAgentRunAbortLifecycleFields,
resolveAgentRunErrorLifecycleFields,
} from "./run-termination.js";
+import { resolveSessionRuntimeOverrideForProvider } from "./session-runtime-compat.js";
import { normalizeSpawnedRunMetadata } from "./spawned-context.js";
+import { resolveCandidateThinkingLevel, resolveEffectiveAgentRuntime } from "./thinking-runtime.js";
import { resolveAgentTimeoutMs } from "./timeout.js";
import { hasNonzeroUsage } from "./usage.js";
import { ensureAgentWorkspace } from "./workspace.js";
@@ -832,11 +833,20 @@ async function prepareAgentCommandExecution(opts: AgentCommandOpts, runtime: Run
workspaceDir,
...modelManifestContext,
});
+ const configuredThinkingRuntime = resolveEffectiveAgentRuntime({
+ cfg,
+ provider: configuredModel.provider,
+ modelId: configuredModel.model,
+ agentId: sessionAgentId,
+ sessionKey,
+ sessionEntry: sessionEntryRaw,
+ });
const thinkingLevelsHint = formatThinkingLevels(
configuredModel.provider,
configuredModel.model,
", ",
configuredThinkingCatalog.length > 0 ? configuredThinkingCatalog : undefined,
+ configuredThinkingRuntime,
);
const thinkOverride = normalizeThinkLevel(opts.thinking);
const thinkOnce = normalizeThinkLevel(opts.thinkingOnce);
@@ -1322,7 +1332,7 @@ async function agentCommandInternal(
});
}
- let resolvedThinkLevel = thinkOnce ?? thinkOverride ?? persistedThinking;
+ const requestedThinkLevel = thinkOnce ?? thinkOverride ?? persistedThinking;
const resolvedVerboseLevel =
verboseOverride ??
persistedVerbose ??
@@ -1397,8 +1407,9 @@ async function agentCommandInternal(
sessionEntry = persisted ?? sessionEntry;
}
- // Persist explicit /command overrides to the session store when we have a key.
- const hasInitialSessionOverrides = Boolean(thinkOverride || verboseOverride);
+ // Persist non-model-dependent command state before provider/model resolution.
+ // Thinking is written only after the selected runtime validates it below.
+ const hasInitialSessionOverrides = Boolean(verboseOverride);
const shouldPersistInitialSessionTouch =
opts.skipInitialSessionTouch !== true || hasInitialSessionOverrides;
if (
@@ -1417,9 +1428,6 @@ async function agentCommandInternal(
sessionStartedAt: entry.sessionStartedAt ?? now,
lastInteractionAt: now,
};
- if (thinkOverride) {
- next.thinkingLevel = thinkOverride;
- }
applyVerboseOverride(next, verboseOverride);
const persisted = await persistSessionEntry({
sessionStore,
@@ -1701,6 +1709,11 @@ async function agentCommandInternal(
modelId: model,
agentId: sessionAgentId,
sessionKey,
+ agentHarnessRuntimeOverride: resolveSessionRuntimeOverrideForProvider({
+ provider,
+ entry: sessionEntry,
+ cfg,
+ }),
workspaceDir,
});
@@ -1781,22 +1794,37 @@ async function agentCommandInternal(
? modelCatalog
: configuredThinkingCatalog;
const thinkingCatalog = catalogForThinking.length > 0 ? catalogForThinking : undefined;
- if (!resolvedThinkLevel) {
- resolvedThinkLevel =
- normalizeThinkLevel(resolveAgentConfig(cfg, sessionAgentId)?.thinkingDefault) ??
- resolveThinkingDefault({
- cfg,
- provider,
- model,
- catalog: thinkingCatalog,
- });
- }
+ const thinkingRuntime = resolveEffectiveAgentRuntime({
+ cfg,
+ provider,
+ modelId: model,
+ agentId: sessionAgentId,
+ sessionKey,
+ sessionEntry: sessionEntryForAttempt,
+ });
+ const configuredThinkLevel = normalizeThinkLevel(
+ resolveAgentConfig(cfg, sessionAgentId)?.thinkingDefault,
+ );
+ // User/session/config choices remain stable across candidates. A model's
+ // own default is resolved again for every fallback or live switch.
+ const immutableThinkLevel = requestedThinkLevel ?? configuredThinkLevel;
+ const primaryThinkLevel =
+ immutableThinkLevel ??
+ resolveThinkingDefault({
+ cfg,
+ provider,
+ model,
+ catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
+ });
+ let effectiveTurnThinkLevel = primaryThinkLevel;
if (
!isThinkingLevelSupported({
provider,
model,
- level: resolvedThinkLevel,
+ level: primaryThinkLevel,
catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
})
) {
const explicitThink = Boolean(thinkOnce || thinkOverride);
@@ -1806,20 +1834,36 @@ async function agentCommandInternal(
// Clamp like the embedded runner; interactive --thinking keeps the throw.
if (explicitThink && !isSubagentSpawnRun) {
throw new Error(
- `Thinking level "${resolvedThinkLevel}" is not supported for ${provider}/${model}. Use one of: ${formatThinkingLevels(provider, model, ", ", thinkingCatalog)}.`,
+ `Thinking level "${primaryThinkLevel}" is not supported for ${provider}/${model}. Use one of: ${formatThinkingLevels(provider, model, ", ", thinkingCatalog, thinkingRuntime)}.`,
);
}
- const fallbackThinkLevel = resolveSupportedThinkingLevel({
- provider,
- model,
- level: resolvedThinkLevel,
- catalog: thinkingCatalog,
+ // Candidate resolution below owns the turn-local clamp. Keep the
+ // requested value immutable so a later fallback can restore it.
+ }
+ if (thinkOverride && sessionStore && sessionKey && !suppressVisibleSessionEffects) {
+ const now = Date.now();
+ const entry = sessionStore[sessionKey] ??
+ sessionEntry ?? { sessionId, updatedAt: now, sessionStartedAt: now };
+ const next: SessionEntry = {
+ ...entry,
+ sessionId,
+ updatedAt: now,
+ sessionStartedAt: entry.sessionStartedAt ?? now,
+ lastInteractionAt: now,
+ thinkingLevel: thinkOverride,
+ };
+ const persisted = await persistSessionEntry({
+ sessionStore,
+ sessionKey,
+ storePath,
+ initialEntry: entry,
+ entry: next,
});
- if (fallbackThinkLevel !== resolvedThinkLevel) {
- // Execution fallbacks are turn-local; directive/model persistence owns
- // durable thinking remaps so explicit session overrides survive runs.
- resolvedThinkLevel = fallbackThinkLevel;
- }
+ sessionEntry = persisted ?? sessionEntry;
+ sessionEntryForAttempt = {
+ ...(sessionEntryForAttempt ?? next),
+ thinkingLevel: thinkOverride,
+ };
}
const { resolveSessionTranscriptFile } = await loadTranscriptResolveRuntime();
let sessionFile: string | undefined;
@@ -2070,6 +2114,12 @@ async function agentCommandInternal(
agentId: sessionAgentId,
sessionId,
sessionKey: sessionKey ?? sessionId,
+ resolveAgentHarnessRuntimeOverride: (candidateProvider) =>
+ resolveSessionRuntimeOverrideForProvider({
+ provider: candidateProvider,
+ entry: sessionEntryForAttempt,
+ cfg,
+ }),
prepareAgentHarnessRuntime: async ({
provider: providerValue,
model: modelValue,
@@ -2143,6 +2193,41 @@ async function agentCommandInternal(
sessionEntry,
});
const fastMode = opts.fastMode ?? fastModeState.mode;
+ const agentHarnessRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
+ provider: providerOverride,
+ entry: attemptSessionEntry,
+ cfg,
+ });
+ const candidateRuntime = resolveEffectiveAgentRuntime({
+ cfg,
+ provider: providerOverride,
+ modelId: modelOverride,
+ agentId: sessionAgentId,
+ sessionKey,
+ sessionEntry: attemptSessionEntry,
+ });
+ const candidateRequestedThinkLevel =
+ immutableThinkLevel ??
+ resolveThinkingDefault({
+ cfg,
+ provider: providerOverride,
+ model: modelOverride,
+ catalog: thinkingCatalog,
+ agentRuntime: candidateRuntime,
+ });
+ const candidateThinkLevel =
+ resolveCandidateThinkingLevel({
+ cfg,
+ provider: providerOverride,
+ modelId: modelOverride,
+ level: candidateRequestedThinkLevel,
+ catalog: thinkingCatalog,
+ agentId: sessionAgentId,
+ sessionKey,
+ sessionEntry: attemptSessionEntry,
+ agentRuntime: candidateRuntime,
+ }) ?? candidateRequestedThinkLevel;
+ effectiveTurnThinkLevel = candidateThinkLevel;
return attemptExecutionRuntime.runAgentAttempt({
providerOverride,
modelOverride,
@@ -2150,6 +2235,7 @@ async function agentCommandInternal(
originalProvider: provider,
cfg,
sessionEntry: attemptSessionEntry,
+ agentHarnessRuntimeOverride,
sessionId,
sessionKey,
sessionAgentId,
@@ -2159,7 +2245,9 @@ async function agentCommandInternal(
body,
transcriptBody,
isFallbackRetry,
- resolvedThinkLevel,
+ // Fallback selection is turn-local. Revalidate the stored or
+ // requested level without rewriting the durable preference.
+ resolvedThinkLevel: candidateThinkLevel,
fastMode,
fastModeStartedAtMs,
fastModeAutoOnSeconds:
@@ -2330,11 +2418,19 @@ async function agentCommandInternal(
providerForAuthProfileValidation = err.provider;
if (sessionEntry) {
sessionEntry = { ...sessionEntry };
+ if (err.agentRuntimeOverride) {
+ sessionEntry.agentRuntimeOverride = err.agentRuntimeOverride;
+ } else {
+ delete sessionEntry.agentRuntimeOverride;
+ }
sessionEntry.authProfileOverride = err.authProfileId;
sessionEntry.authProfileOverrideSource = err.authProfileId
? err.authProfileIdSource
: undefined;
sessionEntry.authProfileOverrideCompactionCount = undefined;
+ // The live switch supersedes any transient auto-fallback probe
+ // snapshot. Retry from the same atomic model/runtime winner.
+ sessionEntryForAttempt = sessionEntry;
}
if (
storedModelOverride ||
@@ -2473,7 +2569,7 @@ async function agentCommandInternal(
messageChannel,
agentAccountId: runContext.accountId,
senderIsOwner: opts.senderIsOwner,
- thinkLevel: resolvedThinkLevel,
+ thinkLevel: effectiveTurnThinkLevel,
extraSystemPrompt: opts.extraSystemPrompt,
});
}
diff --git a/src/agents/cli-runner.spawn.test.ts b/src/agents/cli-runner.spawn.test.ts
index d0accf7d176b..1afb3bfee6a1 100644
--- a/src/agents/cli-runner.spawn.test.ts
+++ b/src/agents/cli-runner.spawn.test.ts
@@ -655,6 +655,24 @@ describe("runCliAgent spawn path", () => {
expect(requireArgAfter(input.argv, "--effort")).toBe("high");
});
+ it("maps Ultra to the strongest generic CLI backend level", async () => {
+ mockSuccessfulClaudeJsonlRun();
+ const resolveExecutionArgs = vi.fn(({ baseArgs }) => baseArgs);
+
+ await executePreparedCliRun(
+ buildPreparedCliRunContext({
+ provider: "claude-cli",
+ model: "sonnet",
+ runId: "run-claude-ultra-args",
+ thinkLevel: "ultra",
+ resolveExecutionArgs,
+ }),
+ );
+
+ const resolveArgsInput = requireRecord(mockCallArg(resolveExecutionArgs), "resolved args");
+ expect(resolveArgsInput.thinkingLevel).toBe("max");
+ });
+
it("passes prepared backend env to the spawned CLI process", async () => {
mockSuccessfulCliRun();
diff --git a/src/agents/cli-runner/execute.ts b/src/agents/cli-runner/execute.ts
index f72ebd21ee2c..1c8d6b532bab 100644
--- a/src/agents/cli-runner/execute.ts
+++ b/src/agents/cli-runner/execute.ts
@@ -29,6 +29,7 @@ import { requestHeartbeat as requestHeartbeatImpl } from "../../infra/heartbeat-
import { sanitizeHostExecEnv } from "../../infra/host-env-security.js";
import { shouldUseInternalSourceReplySink } from "../../infra/outbound/internal-source-reply.js";
import { enqueueSystemEvent as enqueueSystemEventImpl } from "../../infra/system-events.js";
+import type { CliBackendThinkingLevel } from "../../plugins/cli-backend.types.js";
import { getProcessSupervisor as getProcessSupervisorImpl } from "../../process/supervisor/index.js";
import { applySkillEnvOverridesFromSnapshot } from "../../skills/runtime/env-overrides.js";
import { appendBootstrapPromptWarning } from "../bootstrap-budget.js";
@@ -114,6 +115,12 @@ const CLI_MCP_DELIVERY_DRAIN_GRACE_MS = 5_000;
const CLI_MCP_REQUEST_ADMISSION_GRACE_MS = 250;
const OPENCLAW_MCP_TOOL_PREFIX = "mcp__openclaw__";
+function normalizeCliBackendThinkingLevel(
+ level: PreparedCliRunContext["params"]["thinkLevel"],
+): CliBackendThinkingLevel | undefined {
+ return level === "ultra" ? "max" : level;
+}
+
function normalizeCliMessagingToolName(toolName: string): string {
return toolName.startsWith(OPENCLAW_MCP_TOOL_PREFIX)
? toolName.slice(OPENCLAW_MCP_TOOL_PREFIX.length)
@@ -528,7 +535,7 @@ export async function executePreparedCliRun(
provider: params.provider,
modelId: context.modelId,
authProfileId: context.effectiveAuthProfileId,
- thinkingLevel: params.thinkLevel,
+ thinkingLevel: normalizeCliBackendThinkingLevel(params.thinkLevel),
executionMode: params.executionMode ?? "agent",
useResume,
baseArgs: baseArgsWithSkills,
diff --git a/src/agents/command/attempt-execution.cli.test.ts b/src/agents/command/attempt-execution.cli.test.ts
index 94fa52d1cd40..989a11f14f0a 100644
--- a/src/agents/command/attempt-execution.cli.test.ts
+++ b/src/agents/command/attempt-execution.cli.test.ts
@@ -4,8 +4,8 @@ import os from "node:os";
import path from "node:path";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { SessionEntry } from "../../config/sessions.js";
-import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js";
import { resolveSessionTranscriptPath } from "../../config/sessions/paths.js";
+import { clearSessionStoreCacheForTest } from "../../config/sessions/store.js";
import { appendSessionTranscriptMessage } from "../../config/sessions/transcript-append.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createUserTurnTranscriptRecorder } from "../../sessions/user-turn-transcript.js";
@@ -1347,11 +1347,7 @@ describe("CLI attempt execution", () => {
setTestEnvValue("HOME", tmpDir);
setTestEnvValue("OPENCLAW_STATE_DIR", path.join(tmpDir, "state"));
const visibleSessionFile = resolveSessionTranscriptPath(sessionId, "main");
- const internalSessionFile = path.join(
- tmpDir,
- "internal-agent-runs",
- `${sessionId}.jsonl`,
- );
+ const internalSessionFile = path.join(tmpDir, "internal-agent-runs", `${sessionId}.jsonl`);
const sessionEntry: SessionEntry = {
sessionId,
sessionFile: visibleSessionFile,
@@ -3249,6 +3245,55 @@ describe("embedded attempt harness pinning", () => {
});
});
+ it("honors an explicit OpenClaw session runtime override", async () => {
+ const sessionEntry: SessionEntry = {
+ sessionId: "explicit-openclaw-session",
+ updatedAt: Date.now(),
+ agentRuntimeOverride: "openclaw",
+ agentHarnessId: "codex",
+ };
+ runEmbeddedAgentMock.mockResolvedValueOnce({
+ meta: { durationMs: 1 },
+ } satisfies EmbeddedAgentRunResult);
+
+ await runAgentAttempt({
+ providerOverride: "openai",
+ originalProvider: "openai",
+ modelOverride: "gpt-5.6-luna",
+ cfg: {} as OpenClawConfig,
+ sessionEntry,
+ agentHarnessRuntimeOverride: "openclaw",
+ sessionId: sessionEntry.sessionId,
+ sessionKey: "agent:main:main",
+ sessionAgentId: "main",
+ sessionFile: path.join(tmpDir, "session.jsonl"),
+ workspaceDir: tmpDir,
+ body: "continue",
+ isFallbackRetry: false,
+ resolvedThinkLevel: "ultra",
+ timeoutMs: 1_000,
+ runId: "run-explicit-openclaw-runtime",
+ opts: {} as Parameters[0]["opts"],
+ runContext: {} as Parameters[0]["runContext"],
+ spawnedBy: undefined,
+ messageChannel: undefined,
+ skillsSnapshot: undefined,
+ resolvedVerboseLevel: undefined,
+ agentDir: tmpDir,
+ onAgentEvent: vi.fn(),
+ authProfileProvider: "openai",
+ sessionHasHistory: true,
+ });
+
+ expectMockArgFields(runEmbeddedAgentMock, {
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentHarnessId: "openclaw",
+ agentHarnessRuntimeOverride: "openclaw",
+ thinkLevel: "ultra",
+ });
+ });
+
it("routes explicit OpenAI native runs with legacy Codex OAuth through OpenClaw", async () => {
const sessionEntry: SessionEntry = {
sessionId: "explicit-agent-codex-oauth-session",
diff --git a/src/agents/command/attempt-execution.ts b/src/agents/command/attempt-execution.ts
index 148f66d885e4..c55d3c217aa4 100644
--- a/src/agents/command/attempt-execution.ts
+++ b/src/agents/command/attempt-execution.ts
@@ -495,6 +495,7 @@ export function runAgentAttempt(params: {
originalProvider: string;
cfg: OpenClawConfig;
sessionEntry: SessionEntry | undefined;
+ agentHarnessRuntimeOverride?: string;
sessionId: string;
sessionKey: string | undefined;
sessionAgentId: string;
@@ -567,15 +568,24 @@ export function runAgentAttempt(params: {
const bootstrapPromptWarningSignature =
bootstrapPromptWarningSignaturesSeen[bootstrapPromptWarningSignaturesSeen.length - 1];
const requestedAgentHarnessId = isRawModelRun ? "openclaw" : undefined;
+ const sessionRuntimeOverride = isRawModelRun ? undefined : params.agentHarnessRuntimeOverride;
+ const sessionCliRuntime =
+ sessionRuntimeOverride && isCliProvider(sessionRuntimeOverride, params.cfg)
+ ? sessionRuntimeOverride
+ : undefined;
+ const configuredCliRuntime =
+ !isRawModelRun && !sessionRuntimeOverride
+ ? resolveCliRuntimeExecutionProvider({
+ provider: params.providerOverride,
+ cfg: params.cfg,
+ agentId: params.sessionAgentId,
+ modelId: params.modelOverride,
+ authProfileId: params.sessionEntry?.authProfileOverride,
+ })
+ : undefined;
const cliExecutionProvider = isRawModelRun
? params.providerOverride
- : (resolveCliRuntimeExecutionProvider({
- provider: params.providerOverride,
- cfg: params.cfg,
- agentId: params.sessionAgentId,
- modelId: params.modelOverride,
- authProfileId: params.sessionEntry?.authProfileOverride,
- }) ?? params.providerOverride);
+ : (sessionCliRuntime ?? configuredCliRuntime ?? params.providerOverride);
const isCliExecutionProvider = isCliProvider(cliExecutionProvider, params.cfg);
if (params.fallbackRuntimeState && params.fallbackRuntimeState.originRuntime === undefined) {
params.fallbackRuntimeState.originRuntime =
@@ -592,13 +602,15 @@ export function runAgentAttempt(params: {
});
const agentHarnessPolicy = isRawModelRun
? ({ runtime: "openclaw", runtimeSource: "model" } as const)
- : resolveAvailableAgentHarnessPolicy({
- provider: params.providerOverride,
- modelId: params.modelOverride,
- config: params.cfg,
- agentId: params.sessionAgentId,
- sessionKey: params.sessionKey ?? params.sessionId,
- });
+ : sessionRuntimeOverride
+ ? ({ runtime: sessionRuntimeOverride, runtimeSource: "model" } as const)
+ : resolveAvailableAgentHarnessPolicy({
+ provider: params.providerOverride,
+ modelId: params.modelOverride,
+ config: params.cfg,
+ agentId: params.sessionAgentId,
+ sessionKey: params.sessionKey ?? params.sessionId,
+ });
const harnessAuthSelection = resolveHarnessAuthProfileSelection({
config: params.cfg,
agentDir: params.agentDir,
@@ -647,6 +659,7 @@ export function runAgentAttempt(params: {
});
const embeddedAgentHarnessOverride =
requestedAgentHarnessId ??
+ sessionRuntimeOverride ??
(agentHarnessPolicy.runtime === "openclaw" && agentHarnessPolicy.runtimeSource !== "implicit"
? "openclaw"
: undefined);
diff --git a/src/agents/embedded-agent-runner/compact.hooks.test.ts b/src/agents/embedded-agent-runner/compact.hooks.test.ts
index f0e0fc1f5dbb..5d1b9adb3a73 100644
--- a/src/agents/embedded-agent-runner/compact.hooks.test.ts
+++ b/src/agents/embedded-agent-runner/compact.hooks.test.ts
@@ -445,6 +445,51 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
);
});
+ it("maps logical Ultra to max before compaction provider hooks", () => {
+ const resolveExtraParams = vi.fn(() => undefined);
+ compactTesting.prepareCompactionSessionAgent({
+ session: {
+ agent: { streamFn: vi.fn() },
+ messages: [{ role: "user", content: "hello" }],
+ } as never,
+ providerStreamFn: vi.fn(),
+ sessionId: "session-1",
+ signal: new AbortController().signal,
+ effectiveModel: { provider: "openai", id: "fake", api: "responses", input: [] } as never,
+ resolvedApiKey: undefined,
+ authStorage: { setRuntimeApiKey: vi.fn() },
+ config: undefined,
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ thinkLevel: "ultra",
+ sessionAgentId: "main",
+ effectiveWorkspace: "/tmp/workspace",
+ agentDir: "/tmp/workspace",
+ runtimePlan: {
+ auth: {},
+ transport: { resolveExtraParams },
+ } as never,
+ });
+
+ expect(resolveExtraParams).toHaveBeenCalledWith(
+ expect.objectContaining({ thinkingLevel: "max" }),
+ );
+ expect(applyExtraParamsToAgentMock).toHaveBeenCalledWith(
+ expect.anything(),
+ undefined,
+ "openai",
+ "gpt-5.6-sol",
+ undefined,
+ "max",
+ "main",
+ "/tmp/workspace",
+ expect.anything(),
+ "/tmp/workspace",
+ undefined,
+ expect.anything(),
+ );
+ });
+
it("preserves full sender identity when building compaction tools", async () => {
await compactEmbeddedAgentSessionDirect({
sessionId: "session-1",
@@ -655,6 +700,59 @@ describe("compactEmbeddedAgentSessionDirect hooks", () => {
}
});
+ it("revalidates immutable Ultra for each compaction fallback candidate", async () => {
+ resolveAgentHarnessPolicyMock.mockReturnValue({ runtime: "openclaw" });
+ resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({
+ model: { provider, api: "responses", id: modelId, input: [] },
+ error: null,
+ authStorage: { setRuntimeApiKey: vi.fn() },
+ modelRegistry: {},
+ }));
+ sessionCompactImpl
+ .mockRejectedValueOnce(
+ Object.assign(new Error("primary compaction rate limited"), {
+ status: 429,
+ code: "rate_limit_exceeded",
+ }),
+ )
+ .mockResolvedValueOnce({
+ summary: "fallback summary",
+ firstKeptEntryId: "entry-fallback",
+ tokensBefore: 120,
+ details: { ok: true },
+ });
+ const params = {
+ sessionId: "session-1",
+ sessionKey: TEST_SESSION_KEY,
+ sessionFile: "/tmp/session.jsonl",
+ workspaceDir: "/tmp/workspace",
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ thinkLevel: "ultra" as const,
+ trigger: "overflow" as const,
+ modelFallbacksOverride: ["demo/basic"],
+ config: {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } },
+ },
+ },
+ },
+ },
+ };
+
+ const result = await compactEmbeddedAgentSessionDirect(params);
+
+ expect(result.ok).toBe(true);
+ expect(
+ createAgentSessionMock.mock.calls.map(
+ (call) => (call[0] as { thinkingLevel?: string }).thinkingLevel,
+ ),
+ ).toEqual(["ultra", "high"]);
+ expect(params.thinkLevel).toBe("ultra");
+ });
+
it("preserves Codex OAuth across same-provider OpenAI compaction fallbacks", async () => {
resolveModelMock.mockImplementation((provider = "openai", modelId = "fake") => ({
model: { provider, api: "responses", id: modelId, input: [] },
diff --git a/src/agents/embedded-agent-runner/compact.ts b/src/agents/embedded-agent-runner/compact.ts
index 77a03407b1d5..29377003aea0 100644
--- a/src/agents/embedded-agent-runner/compact.ts
+++ b/src/agents/embedded-agent-runner/compact.ts
@@ -131,6 +131,7 @@ import {
} from "../session-write-lock.js";
import { createAgentSession, estimateTokens, SessionManager } from "../sessions/index.js";
import { detectRuntimeShell } from "../shell-utils.js";
+import { resolveCandidateThinkingLevel } from "../thinking-runtime.js";
import {
filterProviderNormalizableTools,
filterRuntimeCompatibleTools,
@@ -198,7 +199,11 @@ import {
import { splitSdkTools } from "./tool-split.js";
import { readTranscriptFileState } from "./transcript-file-state.js";
import type { EmbeddedAgentCompactResult } from "./types.js";
-import { mapThinkingLevel, normalizeContextTokenBudget } from "./utils.js";
+import {
+ mapThinkingLevel,
+ mapThinkingLevelForProvider,
+ normalizeContextTokenBudget,
+} from "./utils.js";
import { flushPendingToolResultsAfterIdle } from "./wait-for-idle-before-flush.js";
export type { CompactEmbeddedAgentSessionParams } from "./compact.types.js";
@@ -271,8 +276,9 @@ function prepareCompactionSessionAgent(params: {
transformSystemPrompt: false,
}) as never;
}
+ const providerThinkingLevel = mapThinkingLevelForProvider(params.thinkLevel);
const preparedRuntimeExtraParams = params.runtimePlan?.transport.resolveExtraParams({
- thinkingLevel: params.thinkLevel,
+ thinkingLevel: providerThinkingLevel,
agentId: params.sessionAgentId,
workspaceDir: params.effectiveWorkspace,
model: params.effectiveModel,
@@ -283,7 +289,7 @@ function prepareCompactionSessionAgent(params: {
params.provider,
params.modelId,
undefined,
- params.thinkLevel,
+ providerThinkingLevel,
params.sessionAgentId,
params.effectiveWorkspace,
params.effectiveModel,
@@ -540,11 +546,21 @@ export async function compactEmbeddedAgentSessionDirect(
const preservesPrimaryAuth =
provider === primaryProvider || provider === requestedPrimaryProvider;
const authProfileId = preservesPrimaryAuth ? params.authProfileId : undefined;
+ const candidateThinkLevel = resolveCandidateThinkingLevel({
+ cfg: params.config,
+ provider,
+ modelId: model,
+ level: params.thinkLevel,
+ agentId: fallbackAgentId,
+ sessionKey: fallbackSessionKey,
+ agentRuntime: params.agentHarnessId,
+ });
return await compactEmbeddedAgentSessionDirectOnce({
...params,
provider,
model,
authProfileId,
+ thinkLevel: candidateThinkLevel,
});
},
});
@@ -882,7 +898,7 @@ async function compactEmbeddedAgentSessionDirectOnce(
workspaceDir: effectiveWorkspace,
agentDir,
agentId: effectiveSkillAgentId,
- thinkingLevel: thinkLevel,
+ thinkingLevel: mapThinkingLevelForProvider(thinkLevel),
});
const runAbortController = new AbortController();
diff --git a/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts b/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts
index 88356d4c5124..435d9f3ca726 100644
--- a/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts
+++ b/src/agents/embedded-agent-runner/extra-params.provider-runtime.test.ts
@@ -4,6 +4,7 @@ import { createLlmStreamSimpleMock } from "../../../test/helpers/agents/llm-stre
import type { Model } from "../../llm/types.js";
import {
testing as extraParamsTesting,
+ resolvePreparedExtraParams,
resolveAgentTransportOverride,
resolveExplicitSettingsTransport,
} from "./extra-params.js";
@@ -44,6 +45,56 @@ afterEach(() => {
});
describe("extra-params: provider runtime handoff", () => {
+ it("keeps provider-ready max stable through provider hooks and cache lookup", () => {
+ const prepareProviderExtraParams = vi.fn(({ context }) => context.extraParams);
+ const resolveProviderExtraParamsForTransport = vi.fn(() => undefined);
+ const wrapProviderStreamFn = vi.fn(({ context }) => context.streamFn);
+ extraParamsTesting.setProviderRuntimeDepsForTest({
+ prepareProviderExtraParams,
+ resolveProviderExtraParamsForTransport,
+ wrapProviderStreamFn,
+ });
+ const cfg = { agents: { defaults: {} } } as never;
+
+ const first = resolvePreparedExtraParams({
+ cfg,
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ thinkingLevel: "max",
+ });
+ const repeated = resolvePreparedExtraParams({
+ cfg,
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ thinkingLevel: "max",
+ });
+
+ expect(first).toBe(repeated);
+ expect(prepareProviderExtraParams).toHaveBeenCalledTimes(1);
+ expect(resolveProviderExtraParamsForTransport).toHaveBeenCalledTimes(1);
+ expect(prepareProviderExtraParams).toHaveBeenCalledWith(
+ expect.objectContaining({ context: expect.objectContaining({ thinkingLevel: "max" }) }),
+ );
+ expect(resolveProviderExtraParamsForTransport).toHaveBeenCalledWith(
+ expect.objectContaining({ context: expect.objectContaining({ thinkingLevel: "max" }) }),
+ );
+
+ runExtraParamsCase({
+ model: {
+ api: "openai-responses",
+ provider: "openai",
+ id: "gpt-5.6-sol",
+ } as unknown as Model<"openai-responses">,
+ thinkingLevel: "max",
+ payload: { model: "gpt-5.6-sol", input: [] },
+ });
+
+ expect(wrapProviderStreamFn).toHaveBeenCalledTimes(1);
+ expect(wrapProviderStreamFn).toHaveBeenCalledWith(
+ expect.objectContaining({ context: expect.objectContaining({ thinkingLevel: "max" }) }),
+ );
+ });
+
it("keeps unsupported upstream transport values out of OpenClaw runtime hooks", () => {
// Upstream transports can name modes OpenClaw does not own; unresolved values
// must be filtered before plugin runtime hooks receive them.
diff --git a/src/agents/embedded-agent-runner/extra-params.test-support.ts b/src/agents/embedded-agent-runner/extra-params.test-support.ts
index 18f7840fb40b..b09fa90e47b4 100644
--- a/src/agents/embedded-agent-runner/extra-params.test-support.ts
+++ b/src/agents/embedded-agent-runner/extra-params.test-support.ts
@@ -1,9 +1,9 @@
// Shared harness for extra-params wrapper tests.
-import type { ThinkLevel } from "../../auto-reply/thinking.shared.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import type { Context, Model, SimpleStreamOptions } from "../../llm/types.js";
import type { StreamFn } from "../runtime/index.js";
import { testing as extraParamsTesting, applyExtraParamsToAgent } from "./extra-params.js";
+import type { ProviderThinkLevel } from "./utils.js";
type ExtraParamsCapture> = {
headers?: Record;
@@ -37,7 +37,7 @@ type RunExtraParamsCaseParams<
mockProviderRuntime?: boolean;
options?: SimpleStreamOptions;
payload: TPayload;
- thinkingLevel?: ThinkLevel;
+ thinkingLevel?: ProviderThinkLevel;
};
export function runExtraParamsCase<
diff --git a/src/agents/embedded-agent-runner/extra-params.ts b/src/agents/embedded-agent-runner/extra-params.ts
index 1502b56461e4..8de89c635391 100644
--- a/src/agents/embedded-agent-runner/extra-params.ts
+++ b/src/agents/embedded-agent-runner/extra-params.ts
@@ -5,7 +5,6 @@ import {
/**
* Resolves model extra parameters and transport overrides for embedded agents.
*/
-import type { ThinkLevel } from "../../auto-reply/thinking.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { createGoogleThinkingPayloadWrapper } from "../../llm/providers/stream-wrappers/google.js";
import { createMinimaxThinkingDisabledWrapper } from "../../llm/providers/stream-wrappers/minimax.js";
@@ -44,6 +43,7 @@ import type { StreamFn } from "../runtime/index.js";
import type { SettingsManager } from "../sessions/index.js";
import { log } from "./logger.js";
import { resolveCacheRetention } from "./prompt-cache-retention.js";
+import type { ProviderThinkLevel } from "./utils.js";
const defaultProviderRuntimeDeps = {
prepareProviderExtraParams: prepareProviderExtraParamsRuntime,
@@ -211,7 +211,7 @@ function resolvePreparedExtraParamsCacheKey(params: {
agentDir?: string;
workspaceDir?: string;
extraParamsOverride?: Record;
- thinkingLevel?: ThinkLevel;
+ thinkingLevel?: ProviderThinkLevel;
agentId?: string;
resolvedExtraParams?: Record;
model?: ProviderRuntimeModel;
@@ -239,7 +239,7 @@ export function resolvePreparedExtraParams(params: {
agentDir?: string;
workspaceDir?: string;
extraParamsOverride?: Record;
- thinkingLevel?: ThinkLevel;
+ thinkingLevel?: ProviderThinkLevel;
agentId?: string;
resolvedExtraParams?: Record;
model?: ProviderRuntimeModel;
@@ -786,7 +786,7 @@ type ApplyExtraParamsContext = {
modelId: string;
agentDir?: string;
workspaceDir?: string;
- thinkingLevel?: ThinkLevel;
+ thinkingLevel?: ProviderThinkLevel;
model?: ProviderRuntimeModel;
effectiveExtraParams: Record;
resolvedExtraParams?: Record;
@@ -1064,7 +1064,7 @@ export function applyExtraParamsToAgent(
provider: string,
modelId: string,
extraParamsOverride?: Record,
- thinkingLevel?: ThinkLevel,
+ thinkingLevel?: ProviderThinkLevel,
agentId?: string,
workspaceDir?: string,
model?: ProviderRuntimeModel,
diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts
index 493fb3cc1287..c40d38b43d6c 100644
--- a/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts
+++ b/src/agents/embedded-agent-runner/run.overflow-compaction.harness.ts
@@ -799,14 +799,18 @@ export async function loadRunOverflowCompactionHarness(): Promise<{
runPostCompactionSideEffects: mockedRunPostCompactionSideEffects,
}));
- vi.doMock("./utils.js", () => ({
- describeUnknownError: vi.fn((err: unknown) => {
- if (err instanceof Error) {
- return err.message;
- }
- return String(err);
- }),
- }));
+ vi.doMock("./utils.js", async () => {
+ const actual = await vi.importActual("./utils.js");
+ return {
+ ...actual,
+ describeUnknownError: vi.fn((err: unknown) => {
+ if (err instanceof Error) {
+ return err.message;
+ }
+ return String(err);
+ }),
+ };
+ });
const { runEmbeddedAgent } = await import("./run.js");
return { runEmbeddedAgent };
diff --git a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts
index 3c69f2622077..b6364d7fbbec 100644
--- a/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts
+++ b/src/agents/embedded-agent-runner/run.overflow-compaction.test.ts
@@ -317,6 +317,44 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
});
});
+ it("revalidates Ultra after a model hook replaces the selected model", async () => {
+ mockedGlobalHookRunner.hasHooks.mockImplementation(
+ (hookName) => hookName === "before_model_resolve",
+ );
+ mockedGlobalHookRunner.runBeforeModelResolve.mockResolvedValueOnce({
+ providerOverride: "openai",
+ modelOverride: "gpt-5.5",
+ });
+ mockedResolveModelAsync.mockResolvedValueOnce({
+ model: {
+ id: "gpt-5.5",
+ provider: "openai",
+ contextWindow: 200000,
+ api: "openai-responses",
+ reasoning: true,
+ },
+ error: null,
+ authStorage: { setRuntimeApiKey: vi.fn() },
+ modelRegistry: {},
+ });
+ mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
+
+ await runEmbeddedAgent({
+ ...overflowBaseRunParams,
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ thinkLevel: "ultra",
+ agentHarnessRuntimeOverride: "openclaw",
+ runId: "run-before-model-resolve-thinking-revalidation",
+ });
+
+ expectMockCallFields(mockedRunEmbeddedAttempt, {
+ provider: "openai",
+ modelId: "gpt-5.5",
+ thinkLevel: "xhigh",
+ });
+ });
+
it("passes resolved auth profile into run attempts for context-engine afterTurn propagation", async () => {
mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
@@ -928,6 +966,25 @@ describe("runEmbeddedAgent overflow compaction trigger routing", () => {
const attemptParams = mockCallArg(mockedRunEmbeddedAttempt) as EmbeddedRunAttemptParams;
expect(attemptParams?.runtimePlan).toBe(runtimePlan);
expect(attemptParams?.internalEvents).toBe(internalEvents);
+ expect(attemptParams?.agentHarnessId).toBe("openclaw");
+ expect(attemptParams?.agentHarnessRuntimeOverride).toBe("openclaw");
+ });
+
+ it("keeps Ultra logical for the attempt and maps the runtime plan to max", async () => {
+ mockedRunEmbeddedAttempt.mockResolvedValueOnce(makeAttemptResult({ promptError: null }));
+
+ await runEmbeddedAgent({
+ ...overflowBaseRunParams,
+ runId: "ultra-runtime-plan-boundary",
+ thinkLevel: "ultra",
+ });
+
+ expect(mockedBuildAgentRuntimePlan).toHaveBeenCalledWith(
+ expect.objectContaining({ thinkingLevel: "max" }),
+ );
+ expect(mockedRunEmbeddedAttempt).toHaveBeenCalledWith(
+ expect.objectContaining({ thinkLevel: "ultra" }),
+ );
});
it("keeps an explicitly captured lifecycle generation across the embedded attempt", async () => {
diff --git a/src/agents/embedded-agent-runner/run.ts b/src/agents/embedded-agent-runner/run.ts
index 8906bb77c12e..2d2b9e217658 100644
--- a/src/agents/embedded-agent-runner/run.ts
+++ b/src/agents/embedded-agent-runner/run.ts
@@ -146,6 +146,7 @@ import {
suspendSession,
type SessionSuspensionParams,
} from "../session-suspension.js";
+import { resolveCandidateThinkingLevel } from "../thinking-runtime.js";
import { DEFAULT_AGENT_TIMEOUT_MS } from "../timeout.js";
import { resolveToolLoopDetectionConfig } from "../tool-loop-detection-config.js";
import { deriveContextPromptTokens, normalizeUsage, type UsageLike } from "../usage.js";
@@ -257,6 +258,7 @@ import type {
ToolSummaryTrace,
} from "./types.js";
import { createUsageAccumulator, mergeUsageIntoAccumulator } from "./usage-accumulator.js";
+import { mapThinkingLevelForProvider } from "./utils.js";
type ApiKeyInfo = ResolvedProviderAuth;
@@ -1059,6 +1061,8 @@ async function runEmbeddedAgentInternal(
hookRunner,
hookContext: hookCtx,
});
+ const modelSelectionChangedByHook =
+ hookSelection.provider !== provider || hookSelection.modelId !== modelId;
provider = hookSelection.provider;
modelId = hookSelection.modelId;
const requestedModelId = modelId;
@@ -1457,13 +1461,36 @@ async function runEmbeddedAgentInternal(
});
};
- const initialThinkLevel = resolveInitialThinkLevel({
+ const requestedThinkLevel = resolveInitialThinkLevel({
requested: params.thinkLevel,
config: params.config,
provider,
modelId,
model: effectiveModel,
});
+ // Hooks can replace the model after outer selection. Revalidate here so the
+ // final model/runtime never receives an unsupported thinking level.
+ const initialThinkLevel = modelSelectionChangedByHook
+ ? (resolveCandidateThinkingLevel({
+ cfg: params.config,
+ provider,
+ modelId,
+ level: requestedThinkLevel,
+ catalog: [
+ {
+ provider,
+ id: modelId,
+ api: effectiveModel.api,
+ reasoning: effectiveModel.reasoning,
+ params: effectiveModel.params,
+ compat: effectiveModel.compat,
+ },
+ ],
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ agentRuntime: agentHarness.id,
+ }) ?? requestedThinkLevel)
+ : requestedThinkLevel;
let thinkLevel = initialThinkLevel;
const attemptedThinking = new Set();
let apiKeyInfo: ApiKeyInfo | null = null;
@@ -2052,7 +2079,7 @@ async function runEmbeddedAgentInternal(
workspaceDir: resolvedWorkspace,
agentDir,
agentId: workspaceResolution.agentId,
- thinkingLevel: thinkLevel,
+ thinkingLevel: mapThinkingLevelForProvider(thinkLevel),
extraParamsOverride: {
...params.streamParams,
fastMode: attemptFastMode,
@@ -2190,6 +2217,7 @@ async function runEmbeddedAgentInternal(
// attempt too. Otherwise plugin-owned transports can skip OpenClaw auth
// bootstrap but drift back to OpenClaw when the attempt is created.
agentHarnessId: agentHarness.id,
+ agentHarnessRuntimeOverride: agentHarness.id,
...(params.sessionKey
? {
agentHarnessTaskRuntimeScope: createAgentHarnessTaskRuntimeScope({
@@ -2517,6 +2545,7 @@ async function runEmbeddedAgentInternal(
defaultModel: DEFAULT_MODEL,
currentProvider: provider,
currentModel: modelId,
+ currentAgentRuntimeOverride: params.agentHarnessRuntimeOverride,
currentAuthProfileId: preferredProfileId,
currentAuthProfileIdSource: params.authProfileIdSource,
});
diff --git a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts
index fc1a024fc1b3..26180077413e 100644
--- a/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts
+++ b/src/agents/embedded-agent-runner/run/attempt.spawn-workspace.test-support.ts
@@ -66,6 +66,7 @@ type SessionManagerMocks = {
type AttemptSpawnWorkspaceHoisted = {
spawnSubagentDirectMock: UnknownMock;
createAgentSessionMock: UnknownMock;
+ applyExtraParamsToAgentMock: UnknownMock;
sessionManagerOpenMock: UnknownMock;
defaultResourceLoaderInitMock: UnknownMock;
resolveSandboxContextMock: UnknownMock;
@@ -146,6 +147,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
// runEmbeddedAttempt captures these dependencies at module load.
const spawnSubagentDirectMock = vi.fn();
const createAgentSessionMock = vi.fn();
+ const applyExtraParamsToAgentMock = vi.fn();
const sessionManagerOpenMock = vi.fn();
const defaultResourceLoaderInitMock = vi.fn();
const resolveSandboxContextMock = vi.fn();
@@ -219,6 +221,7 @@ const hoisted = vi.hoisted((): AttemptSpawnWorkspaceHoisted => {
return {
spawnSubagentDirectMock,
createAgentSessionMock,
+ applyExtraParamsToAgentMock,
sessionManagerOpenMock,
defaultResourceLoaderInitMock,
resolveSandboxContextMock,
@@ -576,7 +579,10 @@ vi.mock("../extra-params.js", async () => {
const actual = await vi.importActual("../extra-params.js");
return {
...actual,
- applyExtraParamsToAgent: () => ({ effectiveExtraParams: {} }),
+ applyExtraParamsToAgent: (...args: unknown[]) => {
+ hoisted.applyExtraParamsToAgentMock(...args);
+ return { effectiveExtraParams: {} };
+ },
resolvePreparedExtraParams: (params: {
cfg?: unknown;
provider: string;
@@ -841,10 +847,13 @@ vi.mock("../tool-split.js", () => ({
}),
}));
-vi.mock("../utils.js", () => ({
- describeUnknownError: (error: unknown) => formatErrorMessage(error),
- mapThinkingLevel: () => undefined,
-}));
+vi.mock("../utils.js", async () => {
+ const actual = await vi.importActual("../utils.js");
+ return {
+ ...actual,
+ describeUnknownError: (error: unknown) => formatErrorMessage(error),
+ };
+});
vi.mock("./compaction-retry-aggregate-timeout.js", () => ({
hasActiveCompactionRetryWork: ({
@@ -968,6 +977,7 @@ export function resetEmbeddedAttemptHarness(
});
}
hoisted.createAgentSessionMock.mockReset();
+ hoisted.applyExtraParamsToAgentMock.mockReset();
hoisted.sessionManagerOpenMock.mockReset().mockReturnValue(hoisted.sessionManager);
hoisted.defaultResourceLoaderInitMock.mockReset();
hoisted.resolveSandboxContextMock.mockReset();
diff --git a/src/agents/embedded-agent-runner/run/attempt.ts b/src/agents/embedded-agent-runner/run/attempt.ts
index 15d749d1a2d2..8f203094d731 100644
--- a/src/agents/embedded-agent-runner/run/attempt.ts
+++ b/src/agents/embedded-agent-runner/run/attempt.ts
@@ -361,7 +361,7 @@ import {
truncateOversizedToolResultsInSessionManager,
} from "../tool-result-truncation.js";
import { splitSdkTools } from "../tool-split.js";
-import { mapThinkingLevel } from "../utils.js";
+import { mapThinkingLevel, mapThinkingLevelForProvider } from "../utils.js";
import { flushPendingToolResultsAfterIdle } from "../wait-for-idle-before-flush.js";
import { abortable as abortableWithSignal } from "./abortable.js";
import { releaseEmbeddedAttemptSessionLockForAbort } from "./attempt-abort.js";
@@ -860,6 +860,11 @@ export async function runEmbeddedAttempt(
): Promise {
const resolvedWorkspace = resolveUserPath(params.workspaceDir);
const runAbortController = new AbortController();
+ // Ultra is a logical orchestration mode, not a provider effort. Preserve it for
+ // prompt/status surfaces, then lower only at agent-core and provider boundaries.
+ const agentCoreThinkingLevel = mapThinkingLevel(params.thinkLevel);
+ const providerThinkingLevel = mapThinkingLevelForProvider(params.thinkLevel);
+ const proactiveSubagentOrchestration = params.thinkLevel === "ultra";
configureEmbeddedAttemptHttpRuntime({ timeoutMs: params.timeoutMs });
log.debug(
@@ -2099,6 +2104,7 @@ export async function runEmbeddedAttempt(
promptMode: effectivePromptMode,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
silentReplyPromptMode: params.silentReplyPromptMode,
+ proactiveSubagentOrchestration,
acpEnabled: isAcpRuntimeSpawnAvailable({
config: params.config,
sandboxed: sandboxInfo?.enabled === true,
@@ -2569,7 +2575,7 @@ export async function runEmbeddedAttempt(
authStorage: params.authStorage,
modelRegistry: params.modelRegistry,
model: params.model,
- thinkingLevel: mapThinkingLevel(params.thinkLevel),
+ thinkingLevel: agentCoreThinkingLevel,
tools: sessionToolAllowlist,
customTools: allCustomTools,
sessionManager,
@@ -2928,7 +2934,7 @@ export async function runEmbeddedAttempt(
};
const preparedRuntimeExtraParams = params.runtimePlan?.transport.resolveExtraParams({
extraParamsOverride: streamExtraParamsOverride,
- thinkingLevel: params.thinkLevel,
+ thinkingLevel: providerThinkingLevel,
agentId: sessionAgentId,
workspaceDir: effectiveWorkspace,
model: params.model,
@@ -2947,7 +2953,7 @@ export async function runEmbeddedAttempt(
provider: params.provider,
modelId: params.modelId,
extraParamsOverride: streamExtraParamsOverride,
- thinkingLevel: params.thinkLevel,
+ thinkingLevel: providerThinkingLevel,
agentId: sessionAgentId,
agentDir,
workspaceDir: effectiveWorkspace,
@@ -3012,7 +3018,7 @@ export async function runEmbeddedAttempt(
params.provider,
params.modelId,
streamExtraParamsOverride,
- params.thinkLevel,
+ providerThinkingLevel,
sessionAgentId,
effectiveWorkspace,
params.model,
diff --git a/src/agents/embedded-agent-runner/run/attempt.ultra-thinking.test.ts b/src/agents/embedded-agent-runner/run/attempt.ultra-thinking.test.ts
new file mode 100644
index 000000000000..c026daa3c5fa
--- /dev/null
+++ b/src/agents/embedded-agent-runner/run/attempt.ultra-thinking.test.ts
@@ -0,0 +1,80 @@
+// Coverage for keeping Ultra logical until the embedded runtime/provider boundary.
+import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import {
+ cleanupTempPaths,
+ createContextEngineAttemptRunner,
+ createContextEngineBootstrapAndAssemble,
+ getHoisted,
+ preloadRunEmbeddedAttemptForTests,
+ resetEmbeddedAttemptHarness,
+} from "./attempt.spawn-workspace.test-support.js";
+
+const hoisted = getHoisted();
+const tempPaths: string[] = [];
+
+beforeAll(async () => {
+ await preloadRunEmbeddedAttemptForTests();
+});
+
+beforeEach(() => {
+ resetEmbeddedAttemptHarness();
+});
+
+afterEach(async () => {
+ await cleanupTempPaths(tempPaths);
+ vi.restoreAllMocks();
+});
+
+describe("runEmbeddedAttempt Ultra thinking", () => {
+ it("enables proactive prompting while giving agent-core max effort", async () => {
+ await createContextEngineAttemptRunner({
+ contextEngine: createContextEngineBootstrapAndAssemble(),
+ sessionKey: "agent:main:main",
+ tempPaths,
+ attemptOverrides: {
+ disableTools: false,
+ thinkLevel: "ultra",
+ },
+ });
+
+ const promptInput = hoisted.embeddedSystemPromptInputs.at(-1) as {
+ defaultThinkLevel?: string;
+ proactiveSubagentOrchestration?: boolean;
+ };
+ const sessionOptions = hoisted.createAgentSessionMock.mock.calls.at(-1)?.[0] as {
+ thinkingLevel?: string;
+ };
+ const providerThinkingLevel = hoisted.applyExtraParamsToAgentMock.mock.calls.at(-1)?.[5];
+
+ expect(promptInput.defaultThinkLevel).toBe("ultra");
+ expect(promptInput.proactiveSubagentOrchestration).toBe(true);
+ expect(sessionOptions.thinkingLevel).toBe("max");
+ expect(providerThinkingLevel).toBe("max");
+ });
+
+ it("keeps explicit max at max without enabling proactive prompting", async () => {
+ await createContextEngineAttemptRunner({
+ contextEngine: createContextEngineBootstrapAndAssemble(),
+ sessionKey: "agent:main:main",
+ tempPaths,
+ attemptOverrides: {
+ disableTools: false,
+ thinkLevel: "max",
+ },
+ });
+
+ const promptInput = hoisted.embeddedSystemPromptInputs.at(-1) as {
+ defaultThinkLevel?: string;
+ proactiveSubagentOrchestration?: boolean;
+ };
+ const sessionOptions = hoisted.createAgentSessionMock.mock.calls.at(-1)?.[0] as {
+ thinkingLevel?: string;
+ };
+ const providerThinkingLevel = hoisted.applyExtraParamsToAgentMock.mock.calls.at(-1)?.[5];
+
+ expect(promptInput.defaultThinkLevel).toBe("max");
+ expect(promptInput.proactiveSubagentOrchestration).toBe(false);
+ expect(sessionOptions.thinkingLevel).toBe("max");
+ expect(providerThinkingLevel).toBe("max");
+ });
+});
diff --git a/src/agents/embedded-agent-runner/system-prompt.test.ts b/src/agents/embedded-agent-runner/system-prompt.test.ts
index cffc1527022d..2bf0a832835f 100644
--- a/src/agents/embedded-agent-runner/system-prompt.test.ts
+++ b/src/agents/embedded-agent-runner/system-prompt.test.ts
@@ -115,6 +115,38 @@ describe("buildEmbeddedSystemPrompt", () => {
expect(prompt).not.toContain("- sessions_spawn: spawn an isolated sub-agent session");
});
+ it("forwards run-scoped proactive orchestration independently of config preference", () => {
+ const prompt = buildEmbeddedSystemPrompt({
+ config: {
+ agents: {
+ defaults: {
+ subagents: {
+ delegationMode: "suggest",
+ },
+ },
+ },
+ },
+ agentId: "main",
+ workspaceDir: "/tmp/openclaw",
+ reasoningTagHint: false,
+ proactiveSubagentOrchestration: true,
+ runtimeInfo: {
+ agentId: "main",
+ host: "local",
+ os: "darwin",
+ arch: "arm64",
+ node: process.version,
+ model: "openai/gpt-5.6-sol",
+ provider: "openai",
+ },
+ tools: [{ name: "sessions_spawn" } as never],
+ userTimezone: "UTC",
+ });
+
+ expect(prompt).toContain("## Proactive Sub-Agent Orchestration");
+ expect(prompt).not.toContain("Mode: prefer");
+ });
+
it("adds workspace-only scratch path guidance when fs workspaceOnly is enabled", () => {
// The prompt must steer writes toward workspace-local scratch paths when
// filesystem tools are constrained to the workspace.
diff --git a/src/agents/embedded-agent-runner/system-prompt.ts b/src/agents/embedded-agent-runner/system-prompt.ts
index 19221a2a6f2a..1a2c02acf088 100644
--- a/src/agents/embedded-agent-runner/system-prompt.ts
+++ b/src/agents/embedded-agent-runner/system-prompt.ts
@@ -47,6 +47,8 @@ export function buildEmbeddedSystemPrompt(params: {
sourceReplyDeliveryMode?: SourceReplyDeliveryMode;
/** Prompt-only strength for delegating non-trivial work through sub-agents. */
subagentDelegationMode?: SubagentDelegationMode;
+ /** Run-scoped Ultra behavior; independent from configured delegation preference. */
+ proactiveSubagentOrchestration?: boolean;
/** Whether ACP-specific routing guidance should be included. Defaults to true. */
acpEnabled?: boolean;
/** Prompt surface controls runtime-specific fallback fragments. Defaults to OpenClaw main. */
@@ -111,6 +113,7 @@ export function buildEmbeddedSystemPrompt(params: {
silentReplyPromptMode: params.silentReplyPromptMode,
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
subagentDelegationMode: params.subagentDelegationMode,
+ proactiveSubagentOrchestration: params.proactiveSubagentOrchestration,
acpEnabled: params.acpEnabled,
promptSurface: params.promptSurface,
nativeCommandNames: params.nativeCommandNames,
diff --git a/src/agents/embedded-agent-runner/utils.test.ts b/src/agents/embedded-agent-runner/utils.test.ts
index ef71270f8b40..28c97b76ae2c 100644
--- a/src/agents/embedded-agent-runner/utils.test.ts
+++ b/src/agents/embedded-agent-runner/utils.test.ts
@@ -1,10 +1,19 @@
// Embedded runner utility tests cover small mapping helpers shared by run setup
// and provider option normalization.
import { describe, expect, it } from "vitest";
-import { mapThinkingLevel } from "./utils.js";
+import { mapThinkingLevel, mapThinkingLevelForProvider } from "./utils.js";
describe("mapThinkingLevel", () => {
it("maps adaptive to the provider-owned high effort default", () => {
expect(mapThinkingLevel("adaptive")).toBe("high");
});
+
+ it("maps logical Ultra to provider max effort", () => {
+ expect(mapThinkingLevel("ultra")).toBe("max");
+ expect(mapThinkingLevelForProvider("ultra")).toBe("max");
+ });
+
+ it("preserves provider-native adaptive outside agent-core", () => {
+ expect(mapThinkingLevelForProvider("adaptive")).toBe("adaptive");
+ });
});
diff --git a/src/agents/embedded-agent-runner/utils.ts b/src/agents/embedded-agent-runner/utils.ts
index 8393afc7a994..879cb012a1de 100644
--- a/src/agents/embedded-agent-runner/utils.ts
+++ b/src/agents/embedded-agent-runner/utils.ts
@@ -4,23 +4,31 @@
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import type { ThinkingLevel } from "../runtime/index.js";
+export type ProviderThinkLevel = Exclude;
+
export function normalizeContextTokenBudget(value: unknown): number | undefined {
return typeof value === "number" && Number.isFinite(value) && value > 0
? Math.floor(value)
: undefined;
}
+/** Converts logical product modes into provider-facing effort values. */
+export function mapThinkingLevelForProvider(level?: ThinkLevel): ProviderThinkLevel | undefined {
+ return level === "ultra" ? "max" : level;
+}
+
export function mapThinkingLevel(level?: ThinkLevel): ThinkingLevel {
// agent runtime supports elevated levels; OpenClaw enables them for specific models.
- if (!level) {
+ const providerLevel = mapThinkingLevelForProvider(level);
+ if (!providerLevel) {
return "off";
}
// Runtime streams do not expose a distinct adaptive level. Preserve the
// provider-owned adaptive default by using Claude's documented high effort.
- if (level === "adaptive") {
+ if (providerLevel === "adaptive") {
return "high";
}
- return level;
+ return providerLevel;
}
export type { ReasoningLevel, ThinkLevel };
diff --git a/src/agents/harness/builtin-openclaw.test.ts b/src/agents/harness/builtin-openclaw.test.ts
new file mode 100644
index 000000000000..7b137c546c66
--- /dev/null
+++ b/src/agents/harness/builtin-openclaw.test.ts
@@ -0,0 +1,23 @@
+// Built-in OpenClaw harness tests cover logical thinking-mode boundaries.
+import { beforeEach, describe, expect, it, vi } from "vitest";
+
+const runEmbeddedAttempt = vi.hoisted(() => vi.fn());
+
+vi.mock("../embedded-agent-runner/run/attempt.js", () => ({ runEmbeddedAttempt }));
+
+import { createOpenClawAgentHarness } from "./builtin-openclaw.js";
+
+describe("createOpenClawAgentHarness", () => {
+ beforeEach(() => {
+ runEmbeddedAttempt.mockReset();
+ runEmbeddedAttempt.mockResolvedValue({});
+ });
+
+ it("preserves logical Ultra for the embedded attempt", async () => {
+ const params = { thinkLevel: "ultra" } as never;
+
+ await createOpenClawAgentHarness().runAttempt(params);
+
+ expect(runEmbeddedAttempt).toHaveBeenCalledWith(params);
+ });
+});
diff --git a/src/agents/harness/selection.ts b/src/agents/harness/selection.ts
index ff8baf830547..0cd414ed1933 100644
--- a/src/agents/harness/selection.ts
+++ b/src/agents/harness/selection.ts
@@ -1,4 +1,3 @@
-import { findNormalizedProviderValue } from "@openclaw/model-catalog-core/provider-id";
/**
* Selects and invokes native agent harnesses for embedded run attempts.
*/
@@ -35,7 +34,8 @@ import {
type AgentHarnessPolicy,
} from "./policy.js";
import { getRegisteredAgentHarness, listRegisteredAgentHarnesses } from "./registry.js";
-import type { AgentHarness, AgentHarnessSupport, AgentHarnessSupportContext } from "./types.js";
+import { buildAgentHarnessSupportContext, compareHarnessSupport } from "./support.js";
+import type { AgentHarness, AgentHarnessSupport } from "./types.js";
const log = createSubsystemLogger("agents/harness");
export { resolveAgentHarnessPolicy } from "./policy.js";
@@ -135,67 +135,6 @@ function applyAgentHarnessAvailabilityPolicy(policy: AgentHarnessPolicy): AgentH
return policy;
}
-function compareHarnessSupport(
- left: { harness: AgentHarness; support: AgentHarnessSupport & { supported: true } },
- right: { harness: AgentHarness; support: AgentHarnessSupport & { supported: true } },
-): number {
- const priorityDelta = (right.support.priority ?? 0) - (left.support.priority ?? 0);
- if (priorityDelta !== 0) {
- return priorityDelta;
- }
- return left.harness.id.localeCompare(right.harness.id);
-}
-
-function buildAgentHarnessSupportContext(params: {
- provider: string;
- modelId?: string;
- requestedRuntime: AgentHarnessSupportContext["requestedRuntime"];
- config?: OpenClawConfig;
-}): AgentHarnessSupportContext {
- const providerOwnership = resolveProviderRefOwnership({
- provider: params.provider,
- config: params.config,
- });
- return {
- provider: params.provider,
- modelId: params.modelId,
- modelProvider: buildAgentHarnessSupportModelProvider(params),
- requestedRuntime: params.requestedRuntime,
- providerOwnerStatus: providerOwnership.status,
- providerOwnerPluginIds:
- providerOwnership.status === "unowned" ? [] : providerOwnership.pluginIds,
- };
-}
-
-function buildAgentHarnessSupportModelProvider(params: {
- provider: string;
- modelId?: string;
- config?: OpenClawConfig;
-}): AgentHarnessSupportContext["modelProvider"] {
- const providerConfig = findNormalizedProviderValue(
- params.config?.models?.providers,
- params.provider,
- );
- if (!providerConfig) {
- return undefined;
- }
- const modelConfig = params.modelId
- ? providerConfig.models?.find((entry) => entry.id === params.modelId)
- : undefined;
- return {
- api: modelConfig?.api ?? providerConfig.api ?? "openai-responses",
- baseUrl: modelConfig?.baseUrl ?? providerConfig.baseUrl,
- azureApiVersion: readStringParam(
- modelConfig?.params?.azureApiVersion ?? providerConfig.params?.azureApiVersion,
- ),
- request: providerConfig.request,
- };
-}
-
-function readStringParam(value: unknown): string | undefined {
- return typeof value === "string" && value.trim() ? value.trim() : undefined;
-}
-
export function selectAgentHarness(params: {
provider: string;
modelId?: string;
@@ -258,6 +197,10 @@ function selectAgentHarnessDecision(params: {
modelId: params.modelId,
requestedRuntime: runtime,
config: params.config,
+ providerOwnership: resolveProviderRefOwnership({
+ provider: params.provider,
+ config: params.config,
+ }),
});
const support = forced.supports(supportContext);
if (support.supported) {
@@ -324,6 +267,10 @@ function selectAgentHarnessDecision(params: {
modelId: params.modelId,
requestedRuntime: runtime,
config: params.config,
+ providerOwnership: resolveProviderRefOwnership({
+ provider: params.provider,
+ config: params.config,
+ }),
});
return pluginHarnesses.map((harness) => ({
harness,
diff --git a/src/agents/harness/support.ts b/src/agents/harness/support.ts
new file mode 100644
index 000000000000..311b27e63e13
--- /dev/null
+++ b/src/agents/harness/support.ts
@@ -0,0 +1,86 @@
+import { findNormalizedProviderValue } from "@openclaw/model-catalog-core/provider-id";
+import type { OpenClawConfig } from "../../config/types.openclaw.js";
+import { listRegisteredAgentHarnesses } from "./registry.js";
+import type { AgentHarness, AgentHarnessSupport, AgentHarnessSupportContext } from "./types.js";
+
+type HarnessProviderOwnership =
+ | { status: "unowned" }
+ | { status: "owned" | "ambiguous"; pluginIds: readonly string[] };
+
+/** Builds the provider/model facts passed to registered harness support probes. */
+export function buildAgentHarnessSupportContext(params: {
+ provider: string;
+ modelId?: string;
+ requestedRuntime: AgentHarnessSupportContext["requestedRuntime"];
+ config?: OpenClawConfig;
+ /** Prepared selection fact; read-only projections omit it to avoid plugin metadata discovery. */
+ providerOwnership?: HarnessProviderOwnership;
+}): AgentHarnessSupportContext {
+ const providerConfig = findNormalizedProviderValue(
+ params.config?.models?.providers,
+ params.provider,
+ );
+ const modelConfig = params.modelId
+ ? providerConfig?.models?.find((entry) => entry.id === params.modelId)
+ : undefined;
+ return {
+ provider: params.provider,
+ modelId: params.modelId,
+ modelProvider: providerConfig
+ ? {
+ api: modelConfig?.api ?? providerConfig.api ?? "openai-responses",
+ baseUrl: modelConfig?.baseUrl ?? providerConfig.baseUrl,
+ azureApiVersion: readStringParam(
+ modelConfig?.params?.azureApiVersion ?? providerConfig.params?.azureApiVersion,
+ ),
+ request: providerConfig.request,
+ }
+ : undefined,
+ requestedRuntime: params.requestedRuntime,
+ ...(params.providerOwnership
+ ? {
+ providerOwnerStatus: params.providerOwnership.status,
+ providerOwnerPluginIds:
+ params.providerOwnership.status === "unowned" ? [] : params.providerOwnership.pluginIds,
+ }
+ : {}),
+ };
+}
+
+/** Resolves the registered plugin harness that auto selection would choose. */
+export function resolveAutoAgentHarnessId(params: {
+ provider: string;
+ modelId?: string;
+ config?: OpenClawConfig;
+}): string | undefined {
+ const supportContext = buildAgentHarnessSupportContext({
+ ...params,
+ requestedRuntime: "auto",
+ });
+ return listRegisteredAgentHarnesses()
+ .map(({ harness }) => ({ harness, support: harness.supports(supportContext) }))
+ .filter(isSupportedHarness)
+ .toSorted(compareHarnessSupport)[0]?.harness.id;
+}
+
+export function compareHarnessSupport(
+ left: { harness: AgentHarness; support: AgentHarnessSupport & { supported: true } },
+ right: { harness: AgentHarness; support: AgentHarnessSupport & { supported: true } },
+): number {
+ const priorityDelta = (right.support.priority ?? 0) - (left.support.priority ?? 0);
+ return priorityDelta !== 0 ? priorityDelta : left.harness.id.localeCompare(right.harness.id);
+}
+
+function isSupportedHarness(entry: {
+ harness: AgentHarness;
+ support: AgentHarnessSupport;
+}): entry is {
+ harness: AgentHarness;
+ support: AgentHarnessSupport & { supported: true };
+} {
+ return entry.support.supported;
+}
+
+function readStringParam(value: unknown): string | undefined {
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
+}
diff --git a/src/agents/live-model-switch-error.ts b/src/agents/live-model-switch-error.ts
index 21bfd3f350d8..3b8bbbff5b71 100644
--- a/src/agents/live-model-switch-error.ts
+++ b/src/agents/live-model-switch-error.ts
@@ -6,6 +6,7 @@
type LiveSessionModelSelection = {
provider: string;
model: string;
+ agentRuntimeOverride?: string;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
};
@@ -14,6 +15,7 @@ type LiveSessionModelSelection = {
export class LiveSessionModelSwitchError extends Error {
provider: string;
model: string;
+ agentRuntimeOverride?: string;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
@@ -22,6 +24,7 @@ export class LiveSessionModelSwitchError extends Error {
this.name = "LiveSessionModelSwitchError";
this.provider = selection.provider;
this.model = selection.model;
+ this.agentRuntimeOverride = selection.agentRuntimeOverride;
this.authProfileId = selection.authProfileId;
this.authProfileIdSource = selection.authProfileIdSource;
}
diff --git a/src/agents/live-model-switch.test.ts b/src/agents/live-model-switch.test.ts
index a94fb5290415..e6ab070b36e2 100644
--- a/src/agents/live-model-switch.test.ts
+++ b/src/agents/live-model-switch.test.ts
@@ -154,6 +154,7 @@ describe("live model switch", () => {
main: {
providerOverride: "openai",
modelOverride: "gpt-5.4",
+ agentRuntimeOverride: "codex",
authProfileOverride: "profile-gpt",
authProfileOverrideSource: "user",
},
@@ -172,6 +173,7 @@ describe("live model switch", () => {
).toEqual({
provider: "openai",
model: "gpt-5.4",
+ agentRuntimeOverride: "codex",
authProfileId: "profile-gpt",
authProfileIdSource: "user",
});
@@ -409,6 +411,25 @@ describe("live model switch", () => {
).toBe(true);
});
+ it("treats a same-model runtime change as a live switch", async () => {
+ const { hasDifferentLiveSessionModelSelection } = await loadModule();
+
+ expect(
+ hasDifferentLiveSessionModelSelection(
+ {
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntimeOverride: "openclaw",
+ },
+ {
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntimeOverride: "codex",
+ },
+ ),
+ ).toBe(true);
+ });
+
it("treats auth-profile-source changes as no-op when no auth profile is selected", async () => {
const { hasDifferentLiveSessionModelSelection } = await loadModule();
@@ -489,6 +510,37 @@ describe("live model switch", () => {
expect(result).toBeUndefined();
});
+ it("returns the persisted selection when only the runtime changed", async () => {
+ state.loadSessionStoreMock.mockReturnValue({
+ main: {
+ liveModelSwitchPending: true,
+ providerOverride: "openai",
+ modelOverride: "gpt-5.6-luna",
+ agentRuntimeOverride: "codex",
+ },
+ });
+
+ const { shouldSwitchToLiveModel } = await loadModule();
+
+ const result = shouldSwitchToLiveModel(
+ makeShouldSwitchParams({
+ currentProvider: "openai",
+ currentModel: "gpt-5.6-luna",
+ currentAgentRuntimeOverride: "openclaw",
+ defaultProvider: "openai",
+ defaultModel: "gpt-5.6-luna",
+ }),
+ );
+
+ expect(result).toEqual({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntimeOverride: "codex",
+ authProfileId: undefined,
+ authProfileIdSource: undefined,
+ });
+ });
+
it("clears the stale liveModelSwitchPending flag when models already match", async () => {
// A stale pending flag should self-heal once the active runtime already
// matches the persisted selection.
diff --git a/src/agents/live-model-switch.ts b/src/agents/live-model-switch.ts
index 5b7f68520748..cfea8ce9036f 100644
--- a/src/agents/live-model-switch.ts
+++ b/src/agents/live-model-switch.ts
@@ -5,15 +5,18 @@ import { normalizeProviderId } from "@openclaw/model-catalog-core/provider-id";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveStorePath } from "../config/sessions/paths.js";
import { loadSessionEntry, patchSessionEntry } from "../config/sessions/session-accessor.js";
+import type { OpenClawConfig } from "../config/types.openclaw.js";
import {
normalizeStoredOverrideModel,
resolveDefaultModelForAgent,
resolvePersistedSelectedModelRef,
} from "./model-selection.js";
+import { resolveSessionRuntimeOverrideForProvider } from "./session-runtime-compat.js";
export { LiveSessionModelSwitchError } from "./live-model-switch-error.js";
export type LiveSessionModelSelection = {
provider: string;
model: string;
+ agentRuntimeOverride?: string;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
};
@@ -22,7 +25,7 @@ const OPENAI_PROVIDER_ID = "openai";
const OPENAI_CODEX_PROVIDER_ID = "openai";
export function resolveLiveSessionModelSelection(params: {
- cfg?: { session?: { store?: string } } | undefined;
+ cfg?: OpenClawConfig | undefined;
sessionKey?: string;
agentId?: string;
defaultProvider: string;
@@ -66,10 +69,16 @@ export function resolveLiveSessionModelSelection(params: {
entry?.providerOverride?.trim() ??
defaultModelRef.provider;
const model = persisted?.model ?? defaultModelRef.model;
+ const agentRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
+ provider,
+ entry,
+ cfg,
+ });
const authProfileId = normalizeOptionalString(entry?.authProfileOverride);
return {
provider,
model,
+ ...(agentRuntimeOverride ? { agentRuntimeOverride } : {}),
authProfileId,
authProfileIdSource: authProfileId ? entry?.authProfileOverrideSource : undefined,
};
@@ -92,6 +101,7 @@ export function hasDifferentLiveSessionModelSelection(
current: {
provider: string;
model: string;
+ agentRuntimeOverride?: string;
authProfileId?: string;
authProfileIdSource?: string;
},
@@ -105,6 +115,7 @@ export function hasDifferentLiveSessionModelSelection(
!isAlreadyAppliedOpenAICodexRuntimePromotion(current, next);
return (
modelSelectionDiffers ||
+ normalizeOptionalString(current.agentRuntimeOverride) !== next.agentRuntimeOverride ||
normalizeOptionalString(current.authProfileId) !== next.authProfileId ||
(normalizeOptionalString(current.authProfileId) ? current.authProfileIdSource : undefined) !==
next.authProfileIdSource
@@ -133,13 +144,14 @@ export function hasDifferentLiveSessionModelSelection(
* user-initiated `/model` switches and system-initiated fallback rotations.
*/
export function shouldSwitchToLiveModel(params: {
- cfg?: { session?: { store?: string } } | undefined;
+ cfg?: OpenClawConfig | undefined;
sessionKey?: string;
agentId?: string;
defaultProvider: string;
defaultModel: string;
currentProvider: string;
currentModel: string;
+ currentAgentRuntimeOverride?: string;
currentAuthProfileId?: string;
currentAuthProfileIdSource?: string;
}): LiveSessionModelSelection | undefined {
@@ -173,6 +185,7 @@ export function shouldSwitchToLiveModel(params: {
{
provider: params.currentProvider,
model: params.currentModel,
+ agentRuntimeOverride: params.currentAgentRuntimeOverride,
authProfileId: params.currentAuthProfileId,
authProfileIdSource: params.currentAuthProfileIdSource,
},
diff --git a/src/agents/model-fallback.test.ts b/src/agents/model-fallback.test.ts
index 4a9342ad81ff..0df2f2bdd849 100644
--- a/src/agents/model-fallback.test.ts
+++ b/src/agents/model-fallback.test.ts
@@ -2171,6 +2171,58 @@ describe("runWithModelFallback", () => {
]);
});
+ it("returns runtime-changing live switches to the retry owner before redirecting", async () => {
+ const cfg = makeCfg({
+ agents: {
+ defaults: {
+ model: {
+ primary: "anthropic/claude-haiku-3-5",
+ fallbacks: ["openai/gpt-5.6-luna"],
+ },
+ },
+ },
+ });
+ const switchError = new LiveSessionModelSwitchError({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntimeOverride: "codex",
+ });
+ const run = vi.fn().mockRejectedValue(switchError);
+
+ await expect(
+ runWithModelFallback({
+ cfg,
+ provider: "anthropic",
+ model: "claude-haiku-3-5",
+ resolveAgentHarnessRuntimeOverride: (provider) =>
+ provider === "openai" ? "openclaw" : undefined,
+ run,
+ }),
+ ).rejects.toBe(switchError);
+ expect(run).toHaveBeenCalledTimes(1);
+ });
+
+ it("returns same-model runtime switches to the retry owner", async () => {
+ const switchError = new LiveSessionModelSwitchError({
+ provider: "openai",
+ model: "gpt-4.1-mini",
+ agentRuntimeOverride: "codex",
+ });
+ const run = vi.fn().mockRejectedValue(switchError);
+
+ await expect(
+ runWithModelFallback({
+ cfg: makeCfg(),
+ provider: "openai",
+ model: "gpt-4.1-mini",
+ fallbacksOverride: [],
+ resolveAgentHarnessRuntimeOverride: () => "openclaw",
+ run,
+ }),
+ ).rejects.toBe(switchError);
+ expect(run).toHaveBeenCalledTimes(1);
+ });
+
it("does not redirect stale live-session switch errors back to the current candidate (#58496 family)", async () => {
const cfg = makeCfg();
const switchError = new LiveSessionModelSwitchError({
diff --git a/src/agents/model-fallback.ts b/src/agents/model-fallback.ts
index 4b228cedca37..7fbcfc7d9c9b 100644
--- a/src/agents/model-fallback.ts
+++ b/src/agents/model-fallback.ts
@@ -565,16 +565,20 @@ function isCliAgentRuntime(runtime: string | undefined, cfg: OpenClawConfig | un
async function resolveModelFallbackCandidateHarnessAuthPrecheck(
params: ModelFallbackRuntimeContext & ModelCandidate,
-): Promise<{ skipsProviderAuthCooldown: boolean }> {
- if (!params.cfg) {
- return { skipsProviderAuthCooldown: false };
- }
+): Promise<{ skipsProviderAuthCooldown: boolean; agentHarnessRuntimeOverride?: string }> {
const agentHarnessRuntimeOverride = params.resolveAgentHarnessRuntimeOverride?.(
params.provider,
params.model,
);
+ const result = (skipsProviderAuthCooldown: boolean) => ({
+ skipsProviderAuthCooldown,
+ agentHarnessRuntimeOverride,
+ });
+ if (!params.cfg) {
+ return result(false);
+ }
if (isCliProvider(params.provider, params.cfg)) {
- return { skipsProviderAuthCooldown: true };
+ return result(true);
}
const agentRuntimeOverride = normalizeOptionalAgentRuntimeId(agentHarnessRuntimeOverride);
const harnessPolicy = resolveAgentHarnessPolicy({
@@ -595,13 +599,13 @@ async function resolveModelFallbackCandidateHarnessAuthPrecheck(
if (isCliAgentRuntime(agentRuntime, params.cfg)) {
// CLI runtimes own their transport/auth, so stale OpenClaw provider
// profile state must not block the candidate before the CLI starts.
- return { skipsProviderAuthCooldown: true };
+ return result(true);
}
if (agentRuntime === "openclaw") {
- return { skipsProviderAuthCooldown: false };
+ return result(false);
}
if (agentRuntime === "auto" || (agentRuntime === "codex" && agentRuntimeSource === "implicit")) {
- return { skipsProviderAuthCooldown: false };
+ return result(false);
}
await params.prepareAgentHarnessRuntime?.({
provider: params.provider,
@@ -613,7 +617,7 @@ async function resolveModelFallbackCandidateHarnessAuthPrecheck(
}
// Explicit non-Codex plugin harnesses own transport/auth; stale OpenClaw
// provider cooldowns must not block the harness before it starts.
- return { skipsProviderAuthCooldown: agentRuntime !== "codex" };
+ return result(agentRuntime !== "codex");
}
function resolveCandidateAttemptError(
@@ -711,6 +715,20 @@ function findLiveSessionModelSwitchRedirectIndex(params: {
return null;
}
+function hasDifferentLiveSessionRuntimeSelection(params: {
+ error: LiveSessionModelSwitchError;
+ currentAgentHarnessRuntimeOverride?: string;
+}): boolean {
+ const normalizeRuntime = (runtime: string | undefined) => {
+ const normalized = normalizeOptionalAgentRuntimeId(runtime);
+ return normalized && !isDefaultAgentRuntimeId(normalized) ? normalized : undefined;
+ };
+ return (
+ normalizeRuntime(params.currentAgentHarnessRuntimeOverride) !==
+ normalizeRuntime(params.error.agentRuntimeOverride)
+ );
+}
+
function throwFallbackFailureSummary(params: {
attempts: FallbackAttempt[];
candidates: ModelCandidate[];
@@ -1851,6 +1869,17 @@ async function runWithModelFallbackInternal(
// so the outer runner cannot loop on the conflicting model, but they
// are not provider overloads.
if (err instanceof LiveSessionModelSwitchError) {
+ // Runtime selection is part of the live switch transaction. The outer
+ // owner must apply it before any retry; redirecting here would pair the
+ // new model with the stale harness runtime captured by the caller.
+ if (
+ hasDifferentLiveSessionRuntimeSelection({
+ error: err,
+ currentAgentHarnessRuntimeOverride: candidateHarnessAuth.agentHarnessRuntimeOverride,
+ })
+ ) {
+ throw err;
+ }
const liveSwitchTargetIndex = findLiveSessionModelSwitchRedirectIndex({
error: err,
candidates,
diff --git a/src/agents/model-selection.ts b/src/agents/model-selection.ts
index c9522dc4bc85..590b95ee65c7 100644
--- a/src/agents/model-selection.ts
+++ b/src/agents/model-selection.ts
@@ -58,15 +58,7 @@ import {
export type { ModelAliasIndex, ModelManifestNormalizationContext, ModelRef, ModelRefStatus };
-export type ThinkLevel =
- | "off"
- | "minimal"
- | "low"
- | "medium"
- | "high"
- | "xhigh"
- | "adaptive"
- | "max";
+export type { ThinkLevel } from "../auto-reply/thinking.shared.js";
export {
buildConfiguredAllowlistKeys,
diff --git a/src/agents/model-thinking-default.ts b/src/agents/model-thinking-default.ts
index 743e79740e4f..f34d9a637621 100644
--- a/src/agents/model-thinking-default.ts
+++ b/src/agents/model-thinking-default.ts
@@ -8,20 +8,20 @@ import {
normalizeOptionalLowercaseString,
} from "@openclaw/normalization-core/string-coerce";
import { resolveThinkingDefaultForModel } from "../auto-reply/thinking.js";
+import type { ThinkLevel } from "../auto-reply/thinking.shared.js";
import type { OpenClawConfig } from "../config/types.openclaw.js";
import type { ModelCatalogEntry } from "./model-catalog.types.js";
import { legacyModelKey, modelKey, normalizeProviderId } from "./model-selection-normalize.js";
import { normalizeModelSelection } from "./model-selection-resolve.js";
import { buildConfiguredModelCatalog } from "./model-selection-shared.js";
-type ThinkLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max";
-
/** Resolves the default thinking level for a provider/model pair. */
export function resolveThinkingDefault(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
catalog?: ModelCatalogEntry[];
+ agentRuntime?: string | null;
}): ThinkLevel {
const normalizedProvider = normalizeProviderId(params.provider);
const normalizedModel = normalizeLowercaseStringOrEmpty(params.model).replace(/\./g, "-");
@@ -63,7 +63,8 @@ export function resolveThinkingDefault(params: {
perModelThinking === "high" ||
perModelThinking === "xhigh" ||
perModelThinking === "adaptive" ||
- perModelThinking === "max"
+ perModelThinking === "max" ||
+ perModelThinking === "ultra"
) {
return perModelThinking;
}
@@ -101,6 +102,7 @@ export function resolveThinkingDefault(params: {
provider: params.provider,
model: params.model,
catalog,
+ agentRuntime: params.agentRuntime,
});
}
@@ -110,6 +112,7 @@ export async function resolveThinkingDefaultWithRuntimeCatalog(params: {
provider: string;
model: string;
loadModelCatalog: () => Promise;
+ agentRuntime?: string | null;
}): Promise {
const configuredCatalog = buildConfiguredModelCatalog({ cfg: params.cfg });
const configuredSelectedEntry = configuredCatalog.find(
@@ -132,5 +135,6 @@ export async function resolveThinkingDefaultWithRuntimeCatalog(params: {
provider: params.provider,
model: params.model,
catalog,
+ agentRuntime: params.agentRuntime,
});
}
diff --git a/src/agents/runtime-plan/build.ts b/src/agents/runtime-plan/build.ts
index 7f1f669d9cbb..a0bbd548b4ea 100644
--- a/src/agents/runtime-plan/build.ts
+++ b/src/agents/runtime-plan/build.ts
@@ -4,7 +4,6 @@
* transport params, delivery, and observability for one attempt.
*/
import type { TSchema } from "typebox";
-import type { ThinkLevel } from "../../auto-reply/thinking.js";
import { isSilentReplyPayloadText, SILENT_REPLY_TOKEN } from "../../auto-reply/tokens.js";
import { projectConfigOntoRuntimeSourceSnapshot } from "../../config/config.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -55,10 +54,6 @@ function asProviderRuntimeModel(
return value !== undefined ? (value as ProviderRuntimeModel) : undefined;
}
-function asThinkLevel(value: BuildAgentRuntimePlanParams["thinkingLevel"]): ThinkLevel | undefined {
- return value !== undefined ? (value as ThinkLevel) : undefined;
-}
-
function isProviderRuntimePluginHandle(
value: BuildAgentRuntimePlanParams["providerRuntimeHandle"] | ProviderRuntimePluginHandle,
): value is ProviderRuntimePluginHandle {
@@ -234,7 +229,7 @@ export function buildAgentRuntimePlan(params: BuildAgentRuntimePlanParams): Agen
agentDir: params.agentDir,
workspaceDir: overrides.workspaceDir ?? params.workspaceDir,
extraParamsOverride: overrides.extraParamsOverride ?? params.extraParamsOverride,
- thinkingLevel: asThinkLevel(overrides.thinkingLevel ?? params.thinkingLevel),
+ thinkingLevel: overrides.thinkingLevel ?? params.thinkingLevel,
agentId: overrides.agentId ?? params.agentId,
model: asProviderRuntimeModel(overrides.model) ?? model,
resolvedTransport: overrides.resolvedTransport ?? transport,
diff --git a/src/agents/runtime-plan/types.compat.test.ts b/src/agents/runtime-plan/types.compat.test.ts
index 0cf00b1760be..175321a86855 100644
--- a/src/agents/runtime-plan/types.compat.test.ts
+++ b/src/agents/runtime-plan/types.compat.test.ts
@@ -21,7 +21,7 @@ type Assert = T;
describe("AgentRuntimePlan structural type compatibility", () => {
it("keeps copied scalar unions aligned with their source contracts", () => {
- expectTypeOf().toEqualTypeOf();
+ expectTypeOf().toEqualTypeOf>();
expectTypeOf().toEqualTypeOf();
expectTypeOf().toEqualTypeOf();
});
diff --git a/src/agents/session-runtime-compat.ts b/src/agents/session-runtime-compat.ts
index b7fc92f74627..e7226ba3fc4f 100644
--- a/src/agents/session-runtime-compat.ts
+++ b/src/agents/session-runtime-compat.ts
@@ -4,14 +4,14 @@
* Resolves persisted runtime overrides without leaking provider-specific CLI runtime bindings across model routes.
*/
import type { SessionEntry } from "../config/sessions.js";
+import type { OpenClawConfig } from "../config/types.openclaw.js";
import { isDefaultAgentRuntimeId } from "./agent-runtime-id.js";
import { normalizeOptionalAgentRuntimeId } from "./agent-runtime-id.js";
+import { isCliRuntimeAliasForProvider } from "./model-runtime-aliases.js";
/** Persisted runtime fields used to recover session runtime compatibility. */
-type SessionRuntimeCompatEntry = Pick<
- SessionEntry,
- "agentHarnessId" | "agentRuntimeOverride"
->;
+type SessionRuntimeCompatEntry = Pick;
+type SessionRuntimeOverrideEntry = Pick;
/** Resolves the persisted runtime id, preferring explicit overrides. */
export function resolvePersistedSessionRuntimeId(
@@ -23,3 +23,25 @@ export function resolvePersistedSessionRuntimeId(
}
return normalizeOptionalAgentRuntimeId(entry?.agentHarnessId);
}
+
+/** Resolves a persisted runtime override only when it can serve the selected provider. */
+export function resolveSessionRuntimeOverrideForProvider(params: {
+ provider?: string | null;
+ entry?: SessionRuntimeOverrideEntry;
+ cfg?: OpenClawConfig;
+}): string | undefined {
+ // agentHarnessId records the runtime that produced the existing transcript;
+ // it must not override the runtime selected for the next turn.
+ const runtime = normalizeOptionalAgentRuntimeId(params.entry?.agentRuntimeOverride);
+ if (!runtime || isDefaultAgentRuntimeId(runtime)) {
+ return undefined;
+ }
+ if (runtime === "openclaw") {
+ return runtime;
+ }
+ const provider = params.provider?.trim().toLowerCase() ?? "";
+ if (provider === "openai" && runtime === "codex") {
+ return runtime;
+ }
+ return isCliRuntimeAliasForProvider({ provider, runtime, cfg: params.cfg }) ? runtime : undefined;
+}
diff --git a/src/agents/simple-completion-runtime.test.ts b/src/agents/simple-completion-runtime.test.ts
index cb093b6f86a2..7d58eecbff8d 100644
--- a/src/agents/simple-completion-runtime.test.ts
+++ b/src/agents/simple-completion-runtime.test.ts
@@ -683,88 +683,90 @@ describe("completeWithPreparedSimpleCompletionModel", () => {
);
});
- it("normalizes OpenClaw-only thinking levels before using shared model runtime simple completion", async () => {
- const model = {
- provider: "openai",
- id: "gpt-5.4",
- name: "gpt-5.4",
- api: "openai-responses",
- baseUrl: "https://api.openai.com/v1",
- reasoning: true,
- input: ["text"],
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 128000,
- maxTokens: 4096,
- } satisfies Model<"openai-responses">;
+ it.each(["max", "ultra"] as const)(
+ "normalizes OpenClaw-only %s before using shared model runtime simple completion",
+ async (reasoning) => {
+ const model = {
+ provider: "openai",
+ id: "gpt-5.4",
+ name: "gpt-5.4",
+ api: "openai-responses",
+ baseUrl: "https://api.openai.com/v1",
+ reasoning: true,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 128000,
+ maxTokens: 4096,
+ } satisfies Model<"openai-responses">;
- await completeWithPreparedSimpleCompletionModel({
- model,
- auth: {
- apiKey: "sk-test",
- source: "env:OPENAI_API_KEY",
- mode: "api-key",
- },
- context: {
- messages: [{ role: "user", content: "pong", timestamp: 1 }],
- },
- options: {
- reasoning: "max",
- },
- });
+ await completeWithPreparedSimpleCompletionModel({
+ model,
+ auth: {
+ apiKey: "sk-test",
+ source: "env:OPENAI_API_KEY",
+ mode: "api-key",
+ },
+ context: {
+ messages: [{ role: "user", content: "pong", timestamp: 1 }],
+ },
+ options: { reasoning },
+ });
- expect(hoisted.completeMock).toHaveBeenCalledWith(
- model,
- {
- messages: [{ role: "user", content: "pong", timestamp: 1 }],
- },
- {
- reasoning: "xhigh",
- apiKey: "sk-test",
- },
- );
- });
+ expect(hoisted.completeMock).toHaveBeenCalledWith(
+ model,
+ {
+ messages: [{ role: "user", content: "pong", timestamp: 1 }],
+ },
+ {
+ reasoning: "xhigh",
+ apiKey: "sk-test",
+ },
+ );
+ },
+ );
- it("preserves max for GPT-5.6 simple completions", async () => {
- const model = {
- provider: "openai",
- id: "gpt-5.6-terra",
- name: "gpt-5.6-terra",
- api: "openai-responses",
- baseUrl: "https://api.openai.com/v1",
- reasoning: true,
- input: ["text"],
- cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
- contextWindow: 372_000,
- maxTokens: 128_000,
- thinkingLevelMap: { xhigh: "xhigh", max: "max" },
- } satisfies Model<"openai-responses">;
+ it.each(["max", "ultra"] as const)(
+ "uses max for GPT-5.6 simple completions requested with %s",
+ async (reasoning) => {
+ const model = {
+ provider: "openai",
+ id: "gpt-5.6-terra",
+ name: "gpt-5.6-terra",
+ api: "openai-responses",
+ baseUrl: "https://api.openai.com/v1",
+ reasoning: true,
+ input: ["text"],
+ cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
+ contextWindow: 372_000,
+ maxTokens: 128_000,
+ thinkingLevelMap: { xhigh: "xhigh", max: "max" },
+ } satisfies Model<"openai-responses">;
- await completeWithPreparedSimpleCompletionModel({
- model,
- auth: {
- apiKey: "sk-test",
- source: "env:OPENAI_API_KEY",
- mode: "api-key",
- },
- context: {
- messages: [{ role: "user", content: "pong", timestamp: 1 }],
- },
- options: {
- reasoning: "max",
- },
- });
+ await completeWithPreparedSimpleCompletionModel({
+ model,
+ auth: {
+ apiKey: "sk-test",
+ source: "env:OPENAI_API_KEY",
+ mode: "api-key",
+ },
+ context: {
+ messages: [{ role: "user", content: "pong", timestamp: 1 }],
+ },
+ options: { reasoning },
+ });
- expect(hoisted.completeMock).toHaveBeenCalledWith(
- model,
- {
- messages: [{ role: "user", content: "pong", timestamp: 1 }],
- },
- {
- reasoning: "max",
- apiKey: "sk-test",
- },
- );
- });
+ expect(hoisted.completeMock).toHaveBeenCalledWith(
+ model,
+ {
+ messages: [{ role: "user", content: "pong", timestamp: 1 }],
+ },
+ {
+ reasoning: "max",
+ apiKey: "sk-test",
+ },
+ );
+ },
+ );
it("omits reasoning for local simple completion when thinking is off", async () => {
const model = {
diff --git a/src/agents/simple-completion-runtime.ts b/src/agents/simple-completion-runtime.ts
index 6f05aef6e565..145bc377cab2 100644
--- a/src/agents/simple-completion-runtime.ts
+++ b/src/agents/simple-completion-runtime.ts
@@ -412,6 +412,7 @@ function normalizeSimpleCompletionReasoning(
return resolveClaudeSonnet5ModelIdentity(model) ? "off" : undefined;
case "adaptive":
return "medium";
+ case "ultra":
case "max":
return isOpenAIProvider(model.provider) && supportsOpenAIReasoningEffort(model, "max")
? "max"
diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts
index d8e29d9814fb..22ead839352c 100644
--- a/src/agents/system-prompt.test.ts
+++ b/src/agents/system-prompt.test.ts
@@ -951,6 +951,49 @@ describe("buildAgentSystemPrompt", () => {
);
});
+ it("adds run-scoped Ultra orchestration only when sessions_spawn is callable", () => {
+ const base = {
+ workspaceDir: "/tmp/openclaw",
+ toolNames: ["sessions_spawn"],
+ subagentDelegationMode: "prefer",
+ } satisfies Parameters[0];
+ const maxPrompt = buildAgentSystemPrompt(base);
+ const ultraPrompt = buildAgentSystemPrompt({
+ ...base,
+ proactiveSubagentOrchestration: true,
+ });
+ const deferredUltraPrompt = buildAgentSystemPrompt({
+ workspaceDir: "/tmp/openclaw",
+ toolNames: ["tool_search"],
+ capabilityToolNames: ["sessions_spawn"],
+ proactiveSubagentOrchestration: true,
+ });
+ const minimalUltraPrompt = buildAgentSystemPrompt({
+ ...base,
+ promptMode: "minimal",
+ proactiveSubagentOrchestration: true,
+ });
+ const unavailablePrompt = buildAgentSystemPrompt({
+ workspaceDir: "/tmp/openclaw",
+ toolNames: ["subagents"],
+ proactiveSubagentOrchestration: true,
+ });
+ const rawPrompt = buildAgentSystemPrompt({
+ ...base,
+ promptMode: "none",
+ proactiveSubagentOrchestration: true,
+ });
+
+ expect(maxPrompt).not.toContain("## Proactive Sub-Agent Orchestration");
+ expect(ultraPrompt).toContain("## Proactive Sub-Agent Orchestration");
+ expect(ultraPrompt).toContain("Ultra mode is active");
+ expect(ultraPrompt).not.toContain("Mode: prefer");
+ expect(deferredUltraPrompt).toContain("## Proactive Sub-Agent Orchestration");
+ expect(minimalUltraPrompt).toContain("## Proactive Sub-Agent Orchestration");
+ expect(unavailablePrompt).not.toContain("## Proactive Sub-Agent Orchestration");
+ expect(rawPrompt).not.toContain("## Proactive Sub-Agent Orchestration");
+ });
+
it("omits prefer delegation guidance when sessions_spawn is unavailable", () => {
const prompt = buildAgentSystemPrompt({
workspaceDir: "/tmp/openclaw",
diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts
index eb2d722c17a8..b1b0a332a023 100644
--- a/src/agents/system-prompt.ts
+++ b/src/agents/system-prompt.ts
@@ -119,6 +119,23 @@ function buildSubagentDelegationPreferenceSection(params: {
].filter(Boolean);
}
+function buildProactiveSubagentOrchestrationSection(params: {
+ enabled: boolean;
+ hasSessionsSpawn: boolean;
+}): string[] {
+ if (!params.enabled || !params.hasSessionsSpawn) {
+ return [];
+ }
+ return [
+ "## Proactive Sub-Agent Orchestration",
+ "Ultra mode is active. Proactively use `sessions_spawn` for independent workstreams when it materially improves speed or quality.",
+ "- Parallelize independent investigation, implementation, and verification when useful.",
+ "- Keep simple or tightly coupled work local; do not delegate just to delegate.",
+ "- Give each child a clear, bounded objective, then synthesize its result before replying.",
+ "",
+ ];
+}
+
const stablePromptPrefixCache = new Map();
function cacheStablePromptPrefix(key: string, build: () => string): string {
@@ -718,6 +735,8 @@ export function buildAgentSystemPrompt(params: {
requireExplicitMessageTarget?: boolean;
/** Prompt-only strength for delegating non-trivial work through sub-agents. Defaults to "suggest". */
subagentDelegationMode?: SubagentDelegationMode;
+ /** Run-scoped Ultra behavior; independent from configured delegation preference. */
+ proactiveSubagentOrchestration?: boolean;
/** Whether ACP-specific routing guidance should be included. Defaults to true. */
acpEnabled?: boolean;
/** Prompt surface controls runtime-specific fallback fragments. Defaults to OpenClaw main. */
@@ -932,6 +951,7 @@ export function buildAgentSystemPrompt(params: {
const promptMode = params.promptMode ?? "full";
const isMinimal = promptMode === "minimal" || promptMode === "none";
const subagentDelegationMode = normalizeSubagentDelegationMode(params.subagentDelegationMode);
+ const proactiveSubagentOrchestration = params.proactiveSubagentOrchestration === true;
const sourceMessageToolOnly = params.sourceReplyDeliveryMode === "message_tool_only";
const messageChannelOptions = availableTools.has("message")
? buildMessageChannelOptions(runtimeChannel)
@@ -1029,6 +1049,7 @@ export function buildAgentSystemPrompt(params: {
sourceMessageToolOnly,
silentReplyPromptMode,
subagentDelegationMode,
+ proactiveSubagentOrchestration,
sandboxInfo: params.sandboxInfo,
displayWorkspaceDir,
workspaceGuidance,
@@ -1096,8 +1117,12 @@ export function buildAgentSystemPrompt(params: {
]
: []),
"",
+ ...buildProactiveSubagentOrchestrationSection({
+ enabled: proactiveSubagentOrchestration,
+ hasSessionsSpawn,
+ }),
...buildSubagentDelegationPreferenceSection({
- mode: subagentDelegationMode,
+ mode: proactiveSubagentOrchestration ? "suggest" : subagentDelegationMode,
isMinimal,
hasSessionsSpawn,
hasSubagents: availableTools.has("subagents"),
diff --git a/src/agents/thinking-runtime.test.ts b/src/agents/thinking-runtime.test.ts
new file mode 100644
index 000000000000..ee8c0a3776df
--- /dev/null
+++ b/src/agents/thinking-runtime.test.ts
@@ -0,0 +1,199 @@
+import { afterAll, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
+import type { OpenClawConfig } from "../config/types.openclaw.js";
+import {
+ clearAgentHarnesses,
+ listRegisteredAgentHarnesses,
+ registerAgentHarness,
+ restoreRegisteredAgentHarnesses,
+} from "./harness/registry.js";
+import type { AgentHarness } from "./harness/types.js";
+import { resolveCandidateThinkingLevel, resolveEffectiveAgentRuntime } from "./thinking-runtime.js";
+
+function openAIConfig(runtime: string): OpenClawConfig {
+ return {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: runtime } },
+ },
+ },
+ },
+ };
+}
+
+describe("resolveEffectiveAgentRuntime", () => {
+ let registeredHarnesses: ReturnType;
+
+ beforeAll(() => {
+ registeredHarnesses = listRegisteredAgentHarnesses();
+ });
+
+ beforeEach(() => {
+ clearAgentHarnesses();
+ });
+
+ afterAll(() => {
+ restoreRegisteredAgentHarnesses(registeredHarnesses);
+ });
+
+ it("keeps cold-start official OpenAI Luna on implicit Codex policy", () => {
+ expect(
+ resolveEffectiveAgentRuntime({
+ cfg: {},
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ }),
+ ).toBe("codex");
+ });
+
+ it("resolves residual auto to OpenClaw when no plugin harness is registered", () => {
+ expect(
+ resolveEffectiveAgentRuntime({
+ cfg: {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "http://127.0.0.1:8080/v1",
+ models: [],
+ },
+ },
+ },
+ },
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ }),
+ ).toBe("openclaw");
+ });
+
+ it("resolves residual auto through a registered Codex harness", () => {
+ const supports = vi.fn(({ provider }) =>
+ provider === "openai" ? { supported: true, priority: 100 } : { supported: false },
+ );
+ const codexHarness: AgentHarness = {
+ id: "codex",
+ label: "Codex",
+ supports,
+ runAttempt: async () => {
+ throw new Error("not exercised");
+ },
+ };
+ registerAgentHarness(codexHarness);
+
+ expect(
+ resolveEffectiveAgentRuntime({
+ cfg: {
+ models: {
+ providers: {
+ openai: {
+ baseUrl: "http://127.0.0.1:8080/v1",
+ models: [],
+ },
+ },
+ },
+ },
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ }),
+ ).toBe("codex");
+ expect(supports).toHaveBeenCalledWith(
+ expect.not.objectContaining({
+ providerOwnerStatus: expect.anything(),
+ providerOwnerPluginIds: expect.anything(),
+ }),
+ );
+ });
+
+ it("prefers explicit session overrides and treats legacy harness ids as observational", () => {
+ const cfg = openAIConfig("openclaw");
+ expect(
+ resolveEffectiveAgentRuntime({
+ cfg,
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ sessionEntry: { agentRuntimeOverride: "codex", agentHarnessId: "openclaw" },
+ }),
+ ).toBe("codex");
+ expect(
+ resolveEffectiveAgentRuntime({
+ cfg,
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ sessionEntry: { agentHarnessId: "codex" },
+ }),
+ ).toBe("openclaw");
+ expect(
+ resolveEffectiveAgentRuntime({
+ cfg,
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ }),
+ ).toBe("openclaw");
+ });
+
+ it("lets an explicit OpenClaw override replace configured Codex policy", () => {
+ expect(
+ resolveEffectiveAgentRuntime({
+ cfg: openAIConfig("codex"),
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ sessionEntry: { agentRuntimeOverride: "openclaw", agentHarnessId: "codex" },
+ }),
+ ).toBe("openclaw");
+ });
+
+ it("keeps a supported candidate level unchanged", () => {
+ expect(
+ resolveCandidateThinkingLevel({
+ cfg: {},
+ provider: "demo",
+ modelId: "demo-model",
+ level: "medium",
+ }),
+ ).toBe("medium");
+ });
+
+ it("clamps an unsupported candidate level without changing the requested value", () => {
+ const requested = "ultra" as const;
+
+ expect(
+ resolveCandidateThinkingLevel({
+ cfg: {},
+ provider: "demo",
+ modelId: "demo-model",
+ level: requested,
+ }),
+ ).toBe("high");
+ expect(requested).toBe("ultra");
+ });
+
+ it("re-evaluates every candidate from the immutable request so later support can upgrade", () => {
+ const cfg: OpenClawConfig = {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ };
+ const requested = "ultra" as const;
+
+ expect(
+ resolveCandidateThinkingLevel({
+ cfg,
+ provider: "openai",
+ modelId: "gpt-5.6-luna",
+ level: requested,
+ }),
+ ).toBe("max");
+ expect(
+ resolveCandidateThinkingLevel({
+ cfg,
+ provider: "openai",
+ modelId: "gpt-5.6-sol",
+ level: requested,
+ }),
+ ).toBe("ultra");
+ });
+});
diff --git a/src/agents/thinking-runtime.ts b/src/agents/thinking-runtime.ts
new file mode 100644
index 000000000000..773aa73e9c92
--- /dev/null
+++ b/src/agents/thinking-runtime.ts
@@ -0,0 +1,92 @@
+import {
+ isThinkingLevelSupported,
+ resolveSupportedThinkingLevel,
+ type ThinkLevel,
+ type ThinkingCatalogEntry,
+} from "../auto-reply/thinking.js";
+/** Resolves the concrete harness runtime that owns the next agent turn. */
+import type { SessionEntry } from "../config/sessions.js";
+import type { OpenClawConfig } from "../config/types.openclaw.js";
+import { resolveAgentHarnessPolicy } from "./harness/policy.js";
+import { resolveAutoAgentHarnessId } from "./harness/support.js";
+import { resolveSessionRuntimeOverrideForProvider } from "./session-runtime-compat.js";
+
+/** Convert residual auto policy into the built-in fallback when no registry selection is needed. */
+export function concretizeAgentRuntime(runtime: string): string {
+ return runtime === "auto" ? "openclaw" : runtime;
+}
+
+/** Resolves an explicit session override before configured model/provider policy. */
+export function resolveEffectiveAgentRuntime(params: {
+ cfg: OpenClawConfig;
+ provider: string;
+ modelId: string;
+ agentId?: string;
+ sessionKey?: string;
+ sessionEntry?: Pick;
+}): string {
+ const sessionRuntime = resolveSessionRuntimeOverrideForProvider({
+ provider: params.provider,
+ entry: params.sessionEntry,
+ cfg: params.cfg,
+ });
+ const runtime =
+ sessionRuntime ??
+ resolveAgentHarnessPolicy({
+ provider: params.provider,
+ modelId: params.modelId,
+ config: params.cfg,
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ }).runtime;
+ if (runtime === "auto") {
+ // Reuse the loaded harness registry without triggering plugin discovery.
+ // This keeps thinking policy aligned with the harness that would own the turn.
+ return (
+ resolveAutoAgentHarnessId({
+ provider: params.provider,
+ modelId: params.modelId,
+ config: params.cfg,
+ }) ?? "openclaw"
+ );
+ }
+ return concretizeAgentRuntime(runtime);
+}
+
+/** Revalidates a turn-local thinking level after fallback selects its actual model/runtime. */
+export function resolveCandidateThinkingLevel(params: {
+ cfg?: OpenClawConfig;
+ provider: string;
+ modelId: string;
+ level?: ThinkLevel;
+ catalog?: ThinkingCatalogEntry[];
+ agentId?: string;
+ sessionKey?: string;
+ sessionEntry?: Pick;
+ /** Concrete harness already selected by the caller, when selection is pinned. */
+ agentRuntime?: string | null;
+}): ThinkLevel | undefined {
+ if (!params.level) {
+ return undefined;
+ }
+ const concreteRuntime = params.agentRuntime?.trim().toLowerCase();
+ const agentRuntime =
+ concreteRuntime && concreteRuntime !== "auto" && concreteRuntime !== "default"
+ ? concreteRuntime
+ : resolveEffectiveAgentRuntime({
+ cfg: params.cfg ?? {},
+ provider: params.provider,
+ modelId: params.modelId,
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ sessionEntry: params.sessionEntry,
+ });
+ const policy = {
+ provider: params.provider,
+ model: params.modelId,
+ level: params.level,
+ catalog: params.catalog,
+ agentRuntime,
+ };
+ return isThinkingLevelSupported(policy) ? params.level : resolveSupportedThinkingLevel(policy);
+}
diff --git a/src/auto-reply/commands-registry.shared.ts b/src/auto-reply/commands-registry.shared.ts
index 5c35d8307329..62386b8fd6d4 100644
--- a/src/auto-reply/commands-registry.shared.ts
+++ b/src/auto-reply/commands-registry.shared.ts
@@ -16,6 +16,7 @@ type ListThinkingLevels = (
provider?: string | null,
model?: string | null,
catalog?: CommandArgChoiceContext["catalog"],
+ agentRuntime?: string | null,
) => string[];
const BROWSER_SAFE_THINKING_LEVELS: ThinkLevel[] = [
@@ -44,6 +45,17 @@ type DefineChatCommandInput = {
tier?: CommandTier;
};
+/**
+ * Keep simple model selections on fast client-side patch paths. Multi-token
+ * forms can carry runtime selectors or a prompt, so the server directive parser
+ * must own the full atomic transaction.
+ */
+export function shouldForwardModelCommandToServer(rawArgs: string): boolean {
+ const args = rawArgs.trim();
+ const normalized = args.toLowerCase();
+ return normalized === "list" || normalized === "status" || /\s/u.test(args);
+}
+
/** Defines one command with normalized aliases, scope, and argument parsing defaults. */
export function defineChatCommand(command: DefineChatCommandInput): ChatCommandDefinition {
const aliases = (command.textAliases ?? (command.textAlias ? [command.textAlias] : []))
@@ -161,8 +173,8 @@ export function buildBuiltinChatCommands(
): ChatCommandDefinition[] {
const configuredThinkingLevels =
params.listThinkingLevels ?? (() => BROWSER_SAFE_THINKING_LEVELS);
- const listThinkingLevelChoices: ListThinkingLevels = (provider, model, catalog) => {
- const levels = configuredThinkingLevels(provider, model, catalog);
+ const listThinkingLevelChoices: ListThinkingLevels = (provider, model, catalog, agentRuntime) => {
+ const levels = configuredThinkingLevels(provider, model, catalog, agentRuntime);
return ["default", ...levels.filter((level) => level !== "default")];
};
const commands: ChatCommandDefinition[] = [
@@ -825,8 +837,8 @@ export function buildBuiltinChatCommands(
name: "level",
description: "Thinking level",
type: "string",
- choices: ({ provider, model, catalog }) =>
- listThinkingLevelChoices(provider, model, catalog),
+ choices: ({ provider, model, catalog, agentRuntime }) =>
+ listThinkingLevelChoices(provider, model, catalog, agentRuntime),
},
],
argsMenu: "auto",
diff --git a/src/auto-reply/commands-registry.test.ts b/src/auto-reply/commands-registry.test.ts
index bb96708134e3..86d4dbdc9ab4 100644
--- a/src/auto-reply/commands-registry.test.ts
+++ b/src/auto-reply/commands-registry.test.ts
@@ -192,6 +192,7 @@ function requireSeenChoice(
seen: {
provider?: string;
model?: string;
+ agentRuntime?: string;
catalogLength?: number;
commandKey: string;
argName: string;
@@ -757,6 +758,7 @@ describe("commands registry args", () => {
let seen: {
provider?: string;
model?: string;
+ agentRuntime?: string;
catalogLength?: number;
commandKey: string;
argName: string;
@@ -774,10 +776,11 @@ describe("commands registry args", () => {
name: "level",
description: "level",
type: "string",
- choices: ({ provider, model, catalog, command: commandLocal, arg }) => {
+ choices: ({ provider, model, agentRuntime, catalog, command: commandLocal, arg }) => {
seen = {
provider,
model,
+ agentRuntime,
catalogLength: catalog?.length,
commandKey: commandLocal.key,
argName: arg.name,
@@ -788,7 +791,12 @@ describe("commands registry args", () => {
],
};
- const menu = requireCommandArgMenu({ command, args: undefined, cfg: {} as never });
+ const menu = requireCommandArgMenu({
+ command,
+ args: undefined,
+ cfg: {} as never,
+ agentRuntime: "codex",
+ });
expect(menu.arg.name).toBe("level");
expect(menu.choices).toEqual([
{ label: "low", value: "low" },
@@ -804,9 +812,32 @@ describe("commands registry args", () => {
expect(seenChoice.provider?.trim().length).toBeGreaterThan(0);
expect(typeof seenChoice.model).toBe("string");
expect(seenChoice.model?.trim().length).toBeGreaterThan(0);
+ expect(seenChoice.agentRuntime).toBe("codex");
expect(seenChoice.catalogLength).toBe(0);
});
+ it.each([
+ { model: "gpt-5.6-sol", agentRuntime: "codex", supportsUltra: true },
+ { model: "gpt-5.6-terra", agentRuntime: "codex", supportsUltra: true },
+ { model: "gpt-5.6-luna", agentRuntime: "codex", supportsUltra: false },
+ { model: "gpt-5.6-luna", agentRuntime: "openclaw", supportsUltra: true },
+ ])(
+ "uses the $agentRuntime thinking profile for openai/$model native menus",
+ ({ model, agentRuntime, supportsUltra }) => {
+ const command = requireNativeCommand("think");
+ const menu = requireCommandArgMenu({
+ command,
+ args: undefined,
+ cfg: {} as never,
+ provider: "openai",
+ model,
+ agentRuntime,
+ });
+
+ expect(menu.choices.some((choice) => choice.value === "ultra")).toBe(supportsUltra);
+ },
+ );
+
it.each([
{
source: "configured",
diff --git a/src/auto-reply/commands-registry.ts b/src/auto-reply/commands-registry.ts
index 785e1c9f9e8d..dcc2e53ef7ec 100644
--- a/src/auto-reply/commands-registry.ts
+++ b/src/auto-reply/commands-registry.ts
@@ -310,6 +310,7 @@ export function resolveCommandArgChoices(params: {
cfg?: OpenClawConfig;
provider?: string;
model?: string;
+ agentRuntime?: string;
catalog?: ThinkingCatalogEntry[];
}): ResolvedCommandArgChoice[] {
const { command, arg, cfg } = params;
@@ -325,6 +326,7 @@ export function resolveCommandArgChoices(params: {
cfg,
provider: params.provider ?? defaults.provider,
model: params.model ?? defaults.model,
+ agentRuntime: params.agentRuntime,
catalog: params.catalog ?? (cfg ? buildConfiguredModelCatalog({ cfg }) : undefined),
command,
arg,
@@ -343,9 +345,10 @@ export function resolveCommandArgMenu(params: {
cfg?: OpenClawConfig;
provider?: string;
model?: string;
+ agentRuntime?: string;
catalog?: ThinkingCatalogEntry[];
}): { arg: CommandArgDefinition; choices: ResolvedCommandArgChoice[]; title?: string } | null {
- const { command, args, cfg, provider, model, catalog } = params;
+ const { command, args, cfg, provider, model, agentRuntime, catalog } = params;
if (!command.args || !command.argsMenu) {
return null;
}
@@ -364,6 +367,7 @@ export function resolveCommandArgMenu(params: {
cfg,
provider,
model,
+ agentRuntime,
catalog: resolvedCatalog,
}).length > 0,
)?.name
@@ -387,6 +391,7 @@ export function resolveCommandArgMenu(params: {
cfg,
provider,
model,
+ agentRuntime,
catalog: resolvedCatalog,
});
if (choices.length === 0) {
diff --git a/src/auto-reply/commands-registry.types.ts b/src/auto-reply/commands-registry.types.ts
index 3fc8f9eb1f31..30ea8d5e45b5 100644
--- a/src/auto-reply/commands-registry.types.ts
+++ b/src/auto-reply/commands-registry.types.ts
@@ -33,6 +33,7 @@ export type CommandArgChoiceContext = {
cfg?: OpenClawConfig;
provider?: string;
model?: string;
+ agentRuntime?: string;
catalog?: ThinkingCatalogEntry[];
command: ChatCommandDefinition;
arg: CommandArgDefinition;
diff --git a/src/auto-reply/reply/agent-runner-execution.test.ts b/src/auto-reply/reply/agent-runner-execution.test.ts
index fb38c53d897e..6378bcf3c824 100644
--- a/src/auto-reply/reply/agent-runner-execution.test.ts
+++ b/src/auto-reply/reply/agent-runner-execution.test.ts
@@ -7,6 +7,7 @@ import { FailoverError } from "../../agents/failover-error.js";
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
import { MissingProviderAuthError } from "../../agents/model-auth.js";
import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { ModelDefinitionConfig } from "../../config/types.models.js";
import {
@@ -30,7 +31,6 @@ import {
buildContextOverflowRecoveryText,
computeContextAwareReserveTokensFloor,
MAX_LIVE_SWITCH_RETRIES,
- resolveSessionRuntimeOverrideForProvider,
resolveRunAfterAutoFallbackPrimaryProbeRecheck,
} from "./agent-runner-execution.js";
import { HEARTBEAT_EXTERNAL_RUN_FAILURE_TEXT } from "./agent-runner-failure-copy.js";
@@ -64,6 +64,15 @@ const EMPTY_INTERACTIVE_REPLY_TEXT =
"I finished the turn, but it did not produce a visible reply. Please try again, or start a new session if this keeps happening.";
describe("resolveSessionRuntimeOverrideForProvider", () => {
+ it("honors an explicit OpenClaw override for OpenAI", () => {
+ expect(
+ resolveSessionRuntimeOverrideForProvider({
+ provider: "openai",
+ entry: { agentRuntimeOverride: "openclaw" } as SessionEntry,
+ }),
+ ).toBe("openclaw");
+ });
+
afterEach(() => {
cliBackendsTesting.resetDepsForTest();
});
@@ -250,6 +259,7 @@ vi.mock("./agent-runner-utils.js", () => ({
model: string;
run: {
provider?: string;
+ thinkLevel?: string;
authProfileId?: string;
authProfileIdSource?: "auto" | "user";
agentAccountId?: string;
@@ -277,6 +287,7 @@ vi.mock("./agent-runner-utils.js", () => ({
runBaseParams: {
provider: params.provider,
model: params.model,
+ thinkLevel: params.run.thinkLevel,
authProfileId: params.provider === params.run.provider ? params.run.authProfileId : undefined,
authProfileIdSource:
params.provider === params.run.provider ? params.run.authProfileIdSource : undefined,
@@ -1382,6 +1393,39 @@ describe("runAgentTurnWithFallback", () => {
expect(embeddedCall.abortSignal).toBe(replyOperation.abortSignal);
});
+ it("revalidates thinking for each main-chat fallback candidate without mutating the run", async () => {
+ const followupRun = createFollowupRun();
+ followupRun.run.provider = "openai";
+ followupRun.run.model = "gpt-5.6-sol";
+ followupRun.run.thinkLevel = "ultra";
+ followupRun.run.config = {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } },
+ },
+ },
+ },
+ };
+ state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
+ await params.run("openai", "gpt-5.6-sol");
+ const result = await params.run("demo", "basic");
+ return { result, provider: "demo", model: "basic", attempts: [] };
+ });
+ state.runEmbeddedAgentMock.mockResolvedValue({ payloads: [{ text: "ok" }], meta: {} });
+
+ const runAgentTurnWithFallback = await getRunAgentTurnWithFallback();
+ await runAgentTurnWithFallback({
+ ...createMinimalRunAgentTurnParams({ followupRun }),
+ });
+
+ expect(state.runEmbeddedAgentMock.mock.calls.map((call) => call[0]?.thinkLevel)).toEqual([
+ "ultra",
+ "high",
+ ]);
+ expect(followupRun.run.thinkLevel).toBe("ultra");
+ });
+
it("freezes abort ownership only after model fallback settles", async () => {
const { replyOperation, freezeAbortMock } = createMockReplyOperation();
state.runWithModelFallbackMock.mockImplementationOnce(async (params: FallbackRunnerParams) => {
@@ -8251,21 +8295,24 @@ describe("runAgentTurnWithFallback", () => {
it("restarts the active prompt when a live model switch is requested", async () => {
let fallbackInvocation = 0;
state.runWithModelFallbackMock.mockImplementation(
- async (params: { run: (provider: string, model: string) => Promise }) => ({
- result: await params.run(
- fallbackInvocation === 0 ? "anthropic" : "openai",
- fallbackInvocation === 0 ? "claude" : "gpt-5.4",
- ),
- provider: fallbackInvocation === 0 ? "anthropic" : "openai",
- model: fallbackInvocation++ === 0 ? "claude" : "gpt-5.4",
- attempts: [],
- }),
+ async (params: { run: (provider: string, model: string) => Promise }) => {
+ const isInitialInvocation = fallbackInvocation++ === 0;
+ const provider = isInitialInvocation ? "anthropic" : "openai";
+ const model = isInitialInvocation ? "claude" : "gpt-5.4";
+ return {
+ result: await params.run(provider, model),
+ provider,
+ model,
+ attempts: [],
+ };
+ },
);
state.runEmbeddedAgentMock
.mockImplementationOnce(async () => {
throw new LiveSessionModelSwitchError({
provider: "openai",
model: "gpt-5.4",
+ agentRuntimeOverride: "codex",
});
})
.mockImplementationOnce(async () => {
@@ -8310,6 +8357,9 @@ describe("runAgentTurnWithFallback", () => {
expect(state.runEmbeddedAgentMock).toHaveBeenCalledTimes(2);
expect(followupRun.run.provider).toBe("openai");
expect(followupRun.run.model).toBe("gpt-5.4");
+ expect(state.runEmbeddedAgentMock.mock.calls[1]?.[0]).toEqual(
+ expect.objectContaining({ agentHarnessRuntimeOverride: "codex" }),
+ );
});
it("breaks out of the retry loop when LiveSessionModelSwitchError is thrown repeatedly (#58348)", async () => {
diff --git a/src/auto-reply/reply/agent-runner-execution.ts b/src/auto-reply/reply/agent-runner-execution.ts
index 4c3e7190c0bc..6401464890b0 100644
--- a/src/auto-reply/reply/agent-runner-execution.ts
+++ b/src/auto-reply/reply/agent-runner-execution.ts
@@ -55,10 +55,7 @@ import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-p
import { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
import { isMissingProviderAuthError } from "../../agents/model-auth.js";
import { runWithModelFallback, isFallbackSummaryError } from "../../agents/model-fallback.js";
-import {
- isCliRuntimeAliasForProvider,
- resolveCliRuntimeExecutionProvider,
-} from "../../agents/model-runtime-aliases.js";
+import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js";
import {
isCliProvider,
resolveModelRefFromString,
@@ -72,6 +69,8 @@ import {
resolveAgentRunErrorLifecycleFields,
} from "../../agents/run-termination.js";
import { buildAgentRuntimeOutcomePlan } from "../../agents/runtime-plan/build.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
+import { resolveCandidateThinkingLevel } from "../../agents/thinking-runtime.js";
import { resolveGroupSessionKey, type SessionEntry } from "../../config/sessions.js";
import { updateSessionEntry } from "../../config/sessions/session-accessor.js";
import { resolveSilentReplyPolicy } from "../../config/silent-reply.js";
@@ -1357,26 +1356,6 @@ function emitModelFallbackStepLifecycle(params: {
});
}
-/** Resolves runtime provider override stored on the session entry. */
-export function resolveSessionRuntimeOverrideForProvider(params: {
- provider: string;
- entry?: Pick;
- cfg?: OpenClawConfig;
-}): string | undefined {
- const provider = normalizeLowercaseStringOrEmpty(params.provider);
- const runtime = normalizeLowercaseStringOrEmpty(params.entry?.agentRuntimeOverride);
- if (!runtime || runtime === "auto" || runtime === "default") {
- return undefined;
- }
- if (provider === "openai" && runtime === "codex") {
- return "codex";
- }
- if (isCliRuntimeAliasForProvider({ provider, runtime, cfg: params.cfg })) {
- return runtime;
- }
- return undefined;
-}
-
/** Decides whether to retry after rechecking auto-fallback primary probe state. */
export function resolveRunAfterAutoFallbackPrimaryProbeRecheck(params: {
run: FollowupRun["run"];
@@ -1534,6 +1513,7 @@ async function runAgentTurnWithFallbackInternal(
}
return effectiveRun;
};
+ let liveModelSwitchRuntimeEntry: Pick | undefined;
const applyLiveModelSwitchToRun = (
run: FollowupRun["run"],
err: LiveSessionModelSwitchError,
@@ -1543,6 +1523,9 @@ async function runAgentTurnWithFallbackInternal(
run.authProfileId = err.authProfileId;
run.authProfileIdSource = err.authProfileId ? err.authProfileIdSource : undefined;
run.autoFallbackPrimaryProbe = undefined;
+ // Keep runtime paired with the error's model/auth winner even if the
+ // active in-memory session snapshot lags the persisted directive write.
+ liveModelSwitchRuntimeEntry = { agentRuntimeOverride: err.agentRuntimeOverride };
};
const runId = params.opts?.runId ?? crypto.randomUUID();
@@ -1917,7 +1900,7 @@ async function runAgentTurnWithFallbackInternal(
resolveAgentHarnessRuntimeOverride: (provider) =>
resolveSessionRuntimeOverrideForProvider({
provider,
- entry: params.getActiveSessionEntry(),
+ entry: liveModelSwitchRuntimeEntry ?? params.getActiveSessionEntry(),
cfg: runtimeConfig,
}),
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
@@ -1960,6 +1943,15 @@ async function runAgentTurnWithFallbackInternal(
const suppressAssistantErrorPersistenceForCandidate =
assistantErrorPersistedAcrossFallback;
const candidateRun = resolveRunForFallbackCandidate(provider, model);
+ const candidateThinkLevel = resolveCandidateThinkingLevel({
+ cfg: runtimeConfig,
+ provider,
+ modelId: model,
+ level: params.followupRun.run.thinkLevel,
+ agentId: params.followupRun.run.agentId,
+ sessionKey: params.followupRun.run.runtimePolicySessionKey ?? params.sessionKey,
+ sessionEntry: params.getActiveSessionEntry(),
+ });
const candidateFastMode = resolveRunFastModeForFallbackCandidate({
run: candidateRun,
config: runtimeConfig,
@@ -1979,14 +1971,14 @@ async function runAgentTurnWithFallbackInternal(
params.opts?.onModelSelected?.({
provider,
model,
- thinkLevel: params.followupRun.run.thinkLevel,
+ thinkLevel: candidateThinkLevel,
});
const { sessionRuntimeOverride, cliExecutionProvider } = agentTurnTiming.measureSync(
"fallback_resolve_runtime",
() => {
const resolvedSessionRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
provider,
- entry: params.getActiveSessionEntry(),
+ entry: liveModelSwitchRuntimeEntry ?? params.getActiveSessionEntry(),
cfg: runtimeConfig,
});
const resolvedSelectedAuthProfile = resolveRunAuthProfile(candidateRun, provider, {
@@ -2180,7 +2172,7 @@ async function runAgentTurnWithFallbackInternal(
inputProvenance: params.followupRun.run.inputProvenance,
provider: cliExecutionProvider,
model,
- thinkLevel: params.followupRun.run.thinkLevel,
+ thinkLevel: candidateThinkLevel,
fastMode: candidateFastMode.fastMode,
fastModeStartedAtMs,
fastModeAutoOnSeconds: candidateFastMode.fastModeAutoOnSeconds,
@@ -2253,7 +2245,7 @@ async function runAgentTurnWithFallbackInternal(
}
const { embeddedContext, senderContext, runBaseParams } =
buildEmbeddedRunExecutionParams({
- run: { ...candidateRun, ...candidateFastMode },
+ run: { ...candidateRun, ...candidateFastMode, thinkLevel: candidateThinkLevel },
replyRoute: params.followupRun,
sessionCtx: params.sessionCtx,
hasRepliedRef: params.opts?.hasRepliedRef,
diff --git a/src/auto-reply/reply/agent-runner-memory.test.ts b/src/auto-reply/reply/agent-runner-memory.test.ts
index 53e026e2f1c5..85a02f46e19d 100644
--- a/src/auto-reply/reply/agent-runner-memory.test.ts
+++ b/src/auto-reply/reply/agent-runner-memory.test.ts
@@ -100,6 +100,7 @@ type ModelFallbackParams = {
type EmbeddedAgentParams = {
provider?: string;
model?: string;
+ thinkLevel?: string;
authProfileId?: unknown;
authProfileIdSource?: unknown;
prompt?: string;
@@ -326,6 +327,64 @@ describe("runMemoryFlushIfNeeded", () => {
expect(persisted.main.memoryFlushAt).toBe(1_700_000_000_000);
});
+ it("revalidates immutable Ultra for each memory-flush fallback candidate", async () => {
+ const storePath = path.join(rootDir, "sessions.json");
+ const sessionKey = "main";
+ const sessionEntry: SessionEntry = {
+ sessionId: "session",
+ updatedAt: Date.now(),
+ totalTokens: 80_000,
+ thinkingLevel: "ultra",
+ };
+ const sessionStore = { [sessionKey]: sessionEntry };
+ await writeTestSessionStore(storePath, sessionKey, sessionEntry);
+ runWithModelFallbackMock.mockImplementationOnce(
+ async (params: { run: (provider: string, model: string) => Promise }) => {
+ await params.run("openai", "gpt-5.6-sol");
+ return {
+ result: await params.run("demo", "basic"),
+ provider: "demo",
+ model: "basic",
+ attempts: [],
+ };
+ },
+ );
+ const followupRun = createTestFollowupRun();
+ followupRun.run.provider = "openai";
+ followupRun.run.model = "gpt-5.6-sol";
+ followupRun.run.thinkLevel = "ultra";
+
+ await runMemoryFlushIfNeeded({
+ cfg: {
+ agents: {
+ defaults: {
+ compaction: { memoryFlush: {} },
+ models: {
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } },
+ },
+ },
+ },
+ },
+ followupRun,
+ sessionCtx: { Provider: "whatsapp" } as unknown as TemplateContext,
+ defaultModel: "openai/gpt-5.6-sol",
+ agentCfgContextTokens: 100_000,
+ resolvedVerboseLevel: "off",
+ sessionEntry,
+ sessionStore,
+ sessionKey,
+ storePath,
+ isHeartbeat: false,
+ replyOperation: createReplyOperation(),
+ });
+
+ expect(runEmbeddedAgentMock.mock.calls.map((call) => call[0]?.thinkLevel)).toEqual([
+ "ultra",
+ "high",
+ ]);
+ expect(followupRun.run.thinkLevel).toBe("ultra");
+ });
+
it("counts resolved error payloads as failed memory flushes", async () => {
const storePath = path.join(rootDir, "sessions.json");
const sessionEntry: SessionEntry = {
@@ -1702,7 +1761,7 @@ describe("runMemoryFlushIfNeeded", () => {
expect(refreshQueuedFollowupSessionMock).not.toHaveBeenCalled();
});
- it("skips OpenClaw preflight compaction for persisted Codex runtime sessions", async () => {
+ it("skips OpenClaw preflight compaction for explicit Codex runtime overrides", async () => {
registerMemoryFlushPlanResolverForTest(() => ({
softThresholdTokens: 4_000,
forceFlushTranscriptBytes: 1_000_000_000,
@@ -1716,7 +1775,8 @@ describe("runMemoryFlushIfNeeded", () => {
updatedAt: Date.now(),
totalTokens: 347_000,
totalTokensFresh: false,
- agentHarnessId: "codex",
+ agentRuntimeOverride: "codex",
+ agentHarnessId: "openclaw",
};
const entry = await runPreflightCompactionIfNeeded({
@@ -1747,7 +1807,7 @@ describe("runMemoryFlushIfNeeded", () => {
expect(compactEmbeddedAgentSessionMock).not.toHaveBeenCalled();
});
- it("skips fresh persisted token totals for persisted Codex runtime sessions", async () => {
+ it("skips fresh persisted token totals for explicit Codex runtime overrides", async () => {
registerMemoryFlushPlanResolverForTest(() => ({
softThresholdTokens: 4_000,
forceFlushTranscriptBytes: 1_000_000_000,
@@ -1761,7 +1821,8 @@ describe("runMemoryFlushIfNeeded", () => {
updatedAt: Date.now(),
totalTokens: 347_000,
totalTokensFresh: true,
- agentHarnessId: "codex",
+ agentRuntimeOverride: "codex",
+ agentHarnessId: "openclaw",
};
const entry = await runPreflightCompactionIfNeeded({
diff --git a/src/auto-reply/reply/agent-runner-memory.ts b/src/auto-reply/reply/agent-runner-memory.ts
index 4b53944f3ba1..000e19858859 100644
--- a/src/auto-reply/reply/agent-runner-memory.ts
+++ b/src/auto-reply/reply/agent-runner-memory.ts
@@ -10,7 +10,6 @@ import { truncateUtf16Safe } from "@openclaw/normalization-core/utf16-slice";
import { resolveBootstrapWarningSignaturesSeen } from "../../agents/bootstrap-budget.js";
import { estimateMessagesTokens } from "../../agents/compaction.js";
import { classifyCompactionReason } from "../../agents/embedded-agent-runner/compact-reasons.js";
-import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
import { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
import { runWithModelFallback } from "../../agents/model-fallback.js";
import { isCliRuntimeAliasForProvider } from "../../agents/model-runtime-aliases.js";
@@ -18,6 +17,11 @@ import { isCliProvider } from "../../agents/model-selection.js";
import { resolveContextConfigProviderForRuntime } from "../../agents/openai-routing.js";
import type { AgentMessage } from "../../agents/runtime/index.js";
import { resolveSandboxConfigForAgent, resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
+import {
+ resolveCandidateThinkingLevel,
+ resolveEffectiveAgentRuntime,
+} from "../../agents/thinking-runtime.js";
import {
deriveContextPromptTokens,
hasNonzeroUsage,
@@ -246,21 +250,6 @@ function resolveMemoryFlushModelFallbackOptions(
};
}
-function resolveMemoryFlushRuntimeOverrideForProvider(params: {
- provider: string;
- entry?: Pick;
-}): string | undefined {
- const provider = normalizeLowercaseStringOrEmpty(params.provider);
- const runtime = normalizeLowercaseStringOrEmpty(params.entry?.agentRuntimeOverride);
- if (!runtime || runtime === "auto" || runtime === "default") {
- return undefined;
- }
- if (provider === "openai" && runtime === "codex") {
- return "codex";
- }
- return undefined;
-}
-
function followupUsesCliRuntime(params: {
cfg: OpenClawConfig;
followupRun: FollowupRun;
@@ -303,30 +292,18 @@ function resolveFollowupAgentRuntimeId(params: {
params.sessionEntry?.sessionId === params.followupRun.run.sessionId
? params.sessionEntry
: undefined;
- const persistedRuntimeOverride = normalizeOptionalString(
- matchingSessionEntry?.agentRuntimeOverride,
- );
- const persistedRuntimeId =
- persistedRuntimeOverride &&
- persistedRuntimeOverride !== "auto" &&
- persistedRuntimeOverride !== "default"
- ? persistedRuntimeOverride
- : matchingSessionEntry?.agentHarnessId;
- if (persistedRuntimeId) {
- return persistedRuntimeId;
- }
- const harnessPolicy = resolveAgentHarnessPolicy({
+ return resolveEffectiveAgentRuntime({
+ cfg: params.cfg,
provider: params.followupRun.run.provider,
modelId: params.followupRun.run.model,
- config: params.cfg,
agentId: params.followupRun.run.agentId,
sessionKey:
params.runtimePolicySessionKey ??
params.sessionKey ??
params.followupRun.run.runtimePolicySessionKey ??
params.followupRun.run.sessionKey,
+ sessionEntry: matchingSessionEntry,
});
- return harnessPolicy.runtime;
}
function followupUsesCodexRuntime(params: {
@@ -1336,9 +1313,10 @@ export async function runMemoryFlushIfNeeded(params: {
lane: CommandLane.Main,
abortSignal: params.replyOperation.abortSignal,
resolveAgentHarnessRuntimeOverride: (provider) =>
- resolveMemoryFlushRuntimeOverrideForProvider({
+ resolveSessionRuntimeOverrideForProvider({
provider,
entry: activeSessionEntry,
+ cfg: params.cfg,
}),
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await memoryDeps.ensureSelectedAgentHarnessPlugin({
@@ -1355,8 +1333,20 @@ export async function runMemoryFlushIfNeeded(params: {
});
},
run: async (provider, model, runOptions) => {
+ const candidateThinkLevel = resolveCandidateThinkingLevel({
+ cfg: params.cfg,
+ provider,
+ modelId: model,
+ level: params.followupRun.run.thinkLevel,
+ agentId: params.followupRun.run.agentId,
+ sessionKey:
+ params.runtimePolicySessionKey ??
+ params.followupRun.run.runtimePolicySessionKey ??
+ params.sessionKey,
+ sessionEntry: activeSessionEntry,
+ });
const { embeddedContext, senderContext, runBaseParams } = buildEmbeddedRunExecutionParams({
- run: params.followupRun.run,
+ run: { ...params.followupRun.run, thinkLevel: candidateThinkLevel },
replyRoute: params.followupRun,
sessionCtx: params.sessionCtx,
hasRepliedRef: params.opts?.hasRepliedRef,
diff --git a/src/auto-reply/reply/commands-status.test.ts b/src/auto-reply/reply/commands-status.test.ts
index 703fcda24a17..55ee29f1dec0 100644
--- a/src/auto-reply/reply/commands-status.test.ts
+++ b/src/auto-reply/reply/commands-status.test.ts
@@ -2195,7 +2195,7 @@ describe("buildStatusReply subagent summary", () => {
}
});
- it("keeps /status on a session-pinned OpenClaw harness after config changes", async () => {
+ it("keeps /status on an explicit OpenClaw runtime override after config changes", async () => {
registerStatusCodexHarness();
const text = await buildStatusText({
@@ -2211,7 +2211,8 @@ describe("buildStatusReply subagent summary", () => {
sessionId: "sess-status-pinned-agent",
updatedAt: 0,
fastMode: true,
- agentHarnessId: "openclaw",
+ agentRuntimeOverride: "openclaw",
+ agentHarnessId: "codex",
},
sessionKey: "agent:main:main",
parentSessionKey: "agent:main:main",
@@ -2234,4 +2235,75 @@ describe("buildStatusReply subagent summary", () => {
expect(normalized).toContain("Fast");
expect(normalized).not.toContain("codex");
});
+
+ it("shows the effective Luna thinking level for a pinned Codex runtime", async () => {
+ registerStatusCodexHarness();
+
+ const text = await buildStatusText({
+ cfg: baseCfg,
+ sessionEntry: {
+ sessionId: "sess-status-luna-codex",
+ updatedAt: 0,
+ thinkingLevel: "ultra",
+ agentRuntimeOverride: "codex",
+ },
+ sessionKey: "agent:main:main",
+ parentSessionKey: "agent:main:main",
+ sessionScope: "per-sender",
+ statusChannel: "mobilechat",
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ contextTokens: 32_000,
+ resolvedThinkLevel: "ultra",
+ resolvedFastMode: false,
+ resolvedVerboseLevel: "off",
+ resolvedReasoningLevel: "off",
+ resolveDefaultThinkingLevel: async () => "ultra",
+ isGroup: false,
+ defaultGroupActivation: () => "mention",
+ modelAuthOverride: "api-key",
+ activeModelAuthOverride: "api-key",
+ });
+
+ const normalized = normalizeTestText(text);
+ expect(normalized).toContain("Think: max");
+ expect(normalized).not.toContain("Think: ultra");
+ });
+
+ it("treats the persisted harness id as observational in /status", async () => {
+ registerStatusCodexHarness();
+
+ const text = await buildStatusText({
+ cfg: {
+ ...baseCfg,
+ agents: {
+ defaults: {
+ agentRuntime: { id: "codex" },
+ },
+ },
+ },
+ sessionEntry: {
+ sessionId: "sess-status-observed-agent",
+ updatedAt: 0,
+ agentHarnessId: "openclaw",
+ },
+ sessionKey: "agent:main:main",
+ parentSessionKey: "agent:main:main",
+ sessionScope: "per-sender",
+ statusChannel: "mobilechat",
+ provider: "openai",
+ model: "gpt-5.4",
+ contextTokens: 32_000,
+ resolvedFastMode: false,
+ resolvedVerboseLevel: "off",
+ resolvedReasoningLevel: "off",
+ resolveDefaultThinkingLevel: async () => undefined,
+ isGroup: false,
+ defaultGroupActivation: () => "mention",
+ modelAuthOverride: "oauth",
+ activeModelAuthOverride: "oauth",
+ });
+
+ expect(normalizeTestText(text)).toContain("Runtime: OpenAI Codex");
+ });
});
diff --git a/src/auto-reply/reply/directive-handling.impl.ts b/src/auto-reply/reply/directive-handling.impl.ts
index d8400888aa5b..c48cef90d2cb 100644
--- a/src/auto-reply/reply/directive-handling.impl.ts
+++ b/src/auto-reply/reply/directive-handling.impl.ts
@@ -10,6 +10,7 @@ import {
resolveFastModeState,
} from "../../agents/fast-mode.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox.js";
+import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js";
import {
adoptPersistedSessionSnapshot,
sessionModelOverrideChangesApplied,
@@ -25,6 +26,10 @@ import {
resolveSupportedThinkingLevel,
} from "../thinking.js";
import type { ReplyPayload } from "../types.js";
+import {
+ applyModelRuntimeDirective,
+ resolveModelRuntimeDirective,
+} from "./directive-handling.model-runtime.js";
import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js";
import { maybeHandleModelDirectiveInfo } from "./directive-handling.model.js";
import type { HandleDirectiveOnlyParams } from "./directive-handling.params.js";
@@ -85,13 +90,14 @@ export async function handleDirectiveOnly(
config: params.cfg,
});
const agentDir = resolveAgentDir(params.cfg, activeAgentId);
+ const runtimePolicySessionKey = resolveRuntimePolicySessionKey({
+ cfg: params.cfg,
+ ctx: params.ctx,
+ sessionKey: params.sessionKey,
+ });
const runtimeIsSandboxed = resolveSandboxRuntimeStatus({
cfg: params.cfg,
- sessionKey: resolveRuntimePolicySessionKey({
- cfg: params.cfg,
- ctx: params.ctx,
- sessionKey: params.sessionKey,
- }),
+ sessionKey: runtimePolicySessionKey,
}).sandboxed;
const shouldHintDirectRuntime = directives.hasElevatedDirective && !runtimeIsSandboxed;
const allowInternalExecPersistence = canPersistSessionDirectiveDefaults({
@@ -148,6 +154,27 @@ export async function handleDirectiveOnly(
const resolvedProvider = modelSelection?.provider ?? provider;
const resolvedModel = modelSelection?.model ?? model;
+ const modelRuntimeResolution = modelSelection
+ ? resolveModelRuntimeDirective({
+ rawRuntime: directives.rawModelRuntime,
+ provider: resolvedProvider,
+ cfg: params.cfg,
+ sessionEntry,
+ })
+ : ({ kind: "unchanged" } as const);
+ if (modelRuntimeResolution.kind === "invalid") {
+ return { text: modelRuntimeResolution.errorText };
+ }
+ const prospectiveSessionEntry = { ...sessionEntry };
+ applyModelRuntimeDirective(prospectiveSessionEntry, modelRuntimeResolution);
+ const thinkingRuntime = resolveEffectiveAgentRuntime({
+ cfg: params.cfg,
+ provider: resolvedProvider,
+ modelId: resolvedModel,
+ agentId: activeAgentId,
+ sessionKey: runtimePolicySessionKey,
+ sessionEntry: prospectiveSessionEntry,
+ });
const thinkingCatalog =
params.thinkingCatalog && params.thinkingCatalog.length > 0
? params.thinkingCatalog
@@ -171,16 +198,22 @@ export async function handleDirectiveOnly(
if (directives.hasThinkDirective && !directives.thinkLevel && !directives.clearThinkLevel) {
// If no argument was provided, show the current level
if (!directives.rawThinkLevel) {
- const level = currentThinkLevel ?? "off";
+ const level = resolveSupportedThinkingLevel({
+ provider: resolvedProvider,
+ model: resolvedModel,
+ level: currentThinkLevel ?? "off",
+ catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
+ });
return {
text: withOptions(
`Current thinking level: ${level}.`,
- `default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog)}`,
+ `default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}`,
),
};
}
return {
- text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog)}.`,
+ text: `Unrecognized thinking level "${directives.rawThinkLevel}". Valid levels: default, ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`,
};
}
if (directives.hasVerboseDirective && !directives.verboseLevel) {
@@ -336,10 +369,11 @@ export async function handleDirectiveOnly(
model: resolvedModel,
level: directives.thinkLevel,
catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
})
) {
return {
- text: `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog)}.`,
+ text: `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, thinkingRuntime)}.`,
};
}
@@ -355,12 +389,14 @@ export async function handleDirectiveOnly(
model: resolvedModel,
level: nextThinkLevel,
catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
})
? resolveSupportedThinkingLevel({
provider: resolvedProvider,
model: resolvedModel,
level: nextThinkLevel,
catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
})
: undefined;
const shouldRemapUnsupportedThinkLevel =
@@ -479,7 +515,8 @@ export async function handleDirectiveOnly(
profileOverride,
markLiveSwitchPending: true,
});
- modelSelectionUpdated = applied.updated;
+ const appliedRuntime = applyModelRuntimeDirective(sessionEntry, modelRuntimeResolution);
+ modelSelectionUpdated = applied.updated || appliedRuntime.updated;
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
@@ -576,8 +613,20 @@ export async function handleDirectiveOnly(
nextProvider: modelSelection.provider,
nextModel: modelSelection.model,
nextModelOverrideSource: "user",
- nextAuthProfileId: profileOverride,
- nextAuthProfileIdSource: profileOverride ? "user" : undefined,
+ nextAuthProfileId: appliedSessionEntry.authProfileOverride,
+ nextAuthProfileIdSource: appliedSessionEntry.authProfileOverrideSource,
+ nextThinking: {
+ level: appliedSessionEntry.thinkingLevel,
+ catalog: thinkingCatalog,
+ agentRuntime: resolveEffectiveAgentRuntime({
+ cfg: params.cfg,
+ provider: modelSelection.provider,
+ modelId: modelSelection.model,
+ agentId: activeAgentId,
+ sessionKey: runtimePolicySessionKey,
+ sessionEntry: appliedSessionEntry,
+ }),
+ },
});
}
}
@@ -718,6 +767,11 @@ export async function handleDirectiveOnly(
if (profileOverride) {
parts.push(`Auth profile set to ${profileOverride}.`);
}
+ if (modelRuntimeResolution.kind === "clear") {
+ parts.push("Runtime reset to configured policy.");
+ } else if (modelRuntimeResolution.kind === "set") {
+ parts.push(`Runtime set to ${modelRuntimeResolution.runtime} for this session.`);
+ }
} else if (modelSelection) {
parts.push("Model change was not applied because the session changed. Retry.");
}
diff --git a/src/auto-reply/reply/directive-handling.mixed-inline.test.ts b/src/auto-reply/reply/directive-handling.mixed-inline.test.ts
index 3b1e5e2cb01d..fc4bf95eba25 100644
--- a/src/auto-reply/reply/directive-handling.mixed-inline.test.ts
+++ b/src/auto-reply/reply/directive-handling.mixed-inline.test.ts
@@ -5,6 +5,7 @@ import type { SessionEntry } from "../../config/sessions.js";
import { applyInlineDirectivesFastLane } from "./directive-handling.fast-lane.js";
import { parseInlineDirectives } from "./directive-handling.parse.js";
import { persistInlineDirectives } from "./directive-handling.persist.js";
+import { refreshQueuedFollowupSession } from "./queue.js";
vi.mock("../../agents/agent-scope.js", () => ({
listAgentEntries: vi.fn(() => []),
@@ -197,6 +198,54 @@ describe("mixed inline directives", () => {
expect(sessionEntry.reasoningLevel).toBe("off");
});
+ it("retargets queued thinking after a mixed-content model switch", async () => {
+ const directives = parseInlineDirectives("please reply /model openai/gpt-5.6-luna");
+ const sessionEntry = createSessionEntry({ thinkingLevel: "ultra" });
+ const sessionKey = "agent:main:dm:1";
+ const cfg = {
+ commands: { text: true },
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ } as unknown as OpenClawConfig;
+
+ await persistInlineDirectives({
+ directives,
+ effectiveModelDirective: directives.rawModelDirective,
+ cfg,
+ sessionEntry,
+ sessionStore: { [sessionKey]: sessionEntry },
+ sessionKey,
+ storePath: undefined,
+ elevatedEnabled: false,
+ elevatedAllowed: false,
+ defaultProvider: "openai",
+ defaultModel: "gpt-5.6-sol",
+ aliasIndex: { byAlias: new Map(), byKey: new Map() },
+ allowedModelKeys: new Set(["openai/gpt-5.6-luna"]),
+ modelCatalog: [{ provider: "openai", id: "gpt-5.6-luna", name: "GPT-5.6-Luna" }],
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ initialModelLabel: "openai/gpt-5.6-sol",
+ formatModelSwitchEvent: (label) => label,
+ agentCfg: cfg.agents?.defaults,
+ });
+
+ expect(sessionEntry.thinkingLevel).toBe("max");
+ expect(refreshQueuedFollowupSession).toHaveBeenCalledWith(
+ expect.objectContaining({
+ key: sessionKey,
+ nextProvider: "openai",
+ nextModel: "gpt-5.6-luna",
+ nextThinking: expect.objectContaining({ level: "max", agentRuntime: "codex" }),
+ }),
+ );
+ });
+
it("emits a channel-neutral ack for reasoning stream", async () => {
const directives = parseInlineDirectives("please reply\n/reasoning stream");
const cfg = createConfig();
diff --git a/src/auto-reply/reply/directive-handling.model-runtime.ts b/src/auto-reply/reply/directive-handling.model-runtime.ts
new file mode 100644
index 000000000000..ad4d43562e71
--- /dev/null
+++ b/src/auto-reply/reply/directive-handling.model-runtime.ts
@@ -0,0 +1,85 @@
+/** Resolves and applies explicit runtime selections attached to `/model`. */
+import {
+ isDefaultAgentRuntimeId,
+ normalizeOptionalAgentRuntimeId,
+} from "../../agents/agent-runtime-id.js";
+import { resolveCliRuntimeModelBackendBinding } from "../../agents/cli-backends.js";
+import { normalizeProviderId } from "../../agents/model-selection.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
+import type { SessionEntry } from "../../config/sessions/types.js";
+import type { OpenClawConfig } from "../../config/types.openclaw.js";
+
+export type ModelRuntimeDirectiveResolution =
+ | { kind: "unchanged" }
+ | { kind: "clear" }
+ | { kind: "set"; runtime: string }
+ | { kind: "invalid"; runtime: string; errorText: string };
+
+/** Validates a requested runtime against the provider selected by the same directive. */
+export function resolveModelRuntimeDirective(params: {
+ rawRuntime?: string;
+ provider: string;
+ cfg: OpenClawConfig;
+ sessionEntry?: Pick;
+}): ModelRuntimeDirectiveResolution {
+ const rawRuntime = params.rawRuntime?.trim();
+ if (!rawRuntime) {
+ const persistedRuntime = params.sessionEntry?.agentRuntimeOverride?.trim();
+ if (
+ persistedRuntime &&
+ !resolveSessionRuntimeOverrideForProvider({
+ provider: params.provider,
+ entry: params.sessionEntry,
+ cfg: params.cfg,
+ })
+ ) {
+ return { kind: "clear" };
+ }
+ return { kind: "unchanged" };
+ }
+
+ const runtime = normalizeOptionalAgentRuntimeId(rawRuntime);
+ if (isDefaultAgentRuntimeId(runtime)) {
+ return { kind: "clear" };
+ }
+ if (runtime === "openclaw") {
+ return { kind: "set", runtime };
+ }
+
+ const provider = normalizeProviderId(params.provider);
+ if (provider === "openai" && runtime === "codex") {
+ return { kind: "set", runtime };
+ }
+ const backend = resolveCliRuntimeModelBackendBinding({
+ config: params.cfg,
+ provider,
+ runtime,
+ });
+ if (backend) {
+ return { kind: "set", runtime: backend.runtime };
+ }
+
+ return {
+ kind: "invalid",
+ runtime: rawRuntime,
+ errorText: `Runtime "${rawRuntime}" is not supported for ${provider || params.provider}.`,
+ };
+}
+
+/** Applies a validated runtime choice without disturbing existing pins when no choice was given. */
+export function applyModelRuntimeDirective(
+ entry: Pick,
+ resolution: ModelRuntimeDirectiveResolution,
+): { updated: boolean } {
+ if (resolution.kind === "clear") {
+ const updated = entry.agentRuntimeOverride !== undefined;
+ delete entry.agentRuntimeOverride;
+ return { updated };
+ }
+ if (resolution.kind === "set") {
+ const updated = entry.agentRuntimeOverride !== resolution.runtime;
+ entry.agentRuntimeOverride = resolution.runtime;
+ return { updated };
+ }
+ return { updated: false };
+}
diff --git a/src/auto-reply/reply/directive-handling.model.test.ts b/src/auto-reply/reply/directive-handling.model.test.ts
index 18a2209e0a9d..48ca35d370a0 100644
--- a/src/auto-reply/reply/directive-handling.model.test.ts
+++ b/src/auto-reply/reply/directive-handling.model.test.ts
@@ -230,6 +230,7 @@ vi.mock("../../agents/provider-auth-aliases.js", () => ({
}));
vi.mock("../../agents/harness/selection.js", () => ({
+ selectAgentHarness: () => ({ id: "openclaw" }),
resolveAgentHarnessPolicy: ({
provider,
modelId,
@@ -404,6 +405,26 @@ function setDirectiveTestProviders(providers: ProviderPlugin[]): void {
setActivePluginRegistry(registry);
}
+function setOpenAiRuntimeScopedUltraProvider(): void {
+ setDirectiveTestProviders([
+ {
+ id: "openai",
+ label: "OpenAI",
+ auth: [],
+ resolveThinkingProfile: ({ agentRuntime }) => ({
+ levels: [
+ { id: "off" },
+ { id: "low" },
+ { id: "medium" },
+ { id: "high" },
+ { id: "max" },
+ ...(agentRuntime === "openclaw" ? ([{ id: "ultra" }] as const) : []),
+ ],
+ }),
+ },
+ ]);
+}
+
beforeEach(() => {
vi.useRealTimers();
cliBackendsTesting.setDepsForTest({
@@ -969,6 +990,46 @@ describe("/model chat UX", () => {
expect(reply?.text).toContain("openai:patrick@example.test=OAuth");
});
+ it("treats the persisted harness id as observational when labeling status auth", async () => {
+ setAuthProfiles({
+ "openai:patrick@example.test": {
+ type: "oauth",
+ provider: "openai",
+ access: "access-token",
+ refresh: "refresh-token",
+ expires: Date.now() + 60_000,
+ },
+ });
+
+ const reply = await resolveModelInfoReply({
+ directives: parseInlineDirectives("/model status"),
+ provider: "openai",
+ model: "gpt-5.5",
+ defaultProvider: "openai",
+ defaultModel: "gpt-5.5",
+ sessionEntry: {
+ agentHarnessId: "codex",
+ },
+ cfg: {
+ commands: { text: true },
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.5" },
+ models: {
+ "openai/gpt-5.5": {
+ agentRuntime: { id: "openclaw" },
+ },
+ },
+ },
+ },
+ } as unknown as OpenClawConfig,
+ allowedModelCatalog: [{ provider: "openai", id: "gpt-5.5", name: "GPT-5.5" }],
+ });
+
+ expect(reply?.text).toContain("[openai] endpoint: default auth: missing");
+ expect(reply?.text).not.toContain("openai:patrick@example.test=OAuth");
+ });
+
it("uses workspace-scoped auth evidence in /model status labels", async () => {
const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-model-status-auth-label-"));
const workspaceDir = path.join(tempRoot, "workspace");
@@ -1245,24 +1306,25 @@ describe("/model chat UX", () => {
expect(sessionEntry.authProfileOverride).toBe(OPENAI_DATE_PROFILE_ID);
});
- it("ignores provider-compatible runtime overrides for mixed-content messages", async () => {
- const { sessionEntry } = await persistModelDirectiveForTest({
+ it("persists provider-compatible runtime overrides for mixed-content messages", async () => {
+ const { persisted, sessionEntry } = await persistModelDirectiveForTest({
command: "/model openai/gpt-4o --runtime codex hello",
allowedModelKeys: ["openai/gpt-4o"],
});
expect(sessionEntry.providerOverride).toBe("openai");
expect(sessionEntry.modelOverride).toBe("gpt-4o");
- expect(sessionEntry.agentRuntimeOverride).toBeUndefined();
+ expect(sessionEntry.agentRuntimeOverride).toBe("codex");
+ expect(persisted.runtimeChange).toEqual({ kind: "set", runtime: "codex" });
});
- it("ignores legacy Codex app-server runtime overrides during persistence", async () => {
+ it("normalizes legacy Codex app-server runtime overrides during persistence", async () => {
const { sessionEntry } = await persistModelDirectiveForTest({
command: "/model openai/gpt-4o --runtime codex-app-server hello",
allowedModelKeys: ["openai/gpt-4o"],
});
- expect(sessionEntry.agentRuntimeOverride).toBeUndefined();
+ expect(sessionEntry.agentRuntimeOverride).toBe("codex");
});
it("uses Codex OAuth context config for persisted native Codex runtime directives", async () => {
@@ -1337,25 +1399,94 @@ describe("/model chat UX", () => {
expect(sessionEntry.agentRuntimeOverride).toBeUndefined();
});
- it("ignores runtime overrides that do not belong to the selected provider", async () => {
- vi.mocked(enqueueSystemEvent).mockClear();
- const { sessionEntry } = await persistModelDirectiveForTest({
- command: "/model openai/gpt-4o --runtime claude-cli hello",
- allowedModelKeys: ["openai/gpt-4o"],
- sessionEntry: createSessionEntry({ agentRuntimeOverride: "openclaw" }),
+ it("clears a provider-incompatible runtime pin during a model switch", async () => {
+ const sessionEntry = createSessionEntry({
+ providerOverride: "openai",
+ modelOverride: "gpt-4o",
+ modelOverrideSource: "user",
+ agentRuntimeOverride: "codex",
+ });
+ const { persisted } = await persistModelDirectiveForTest({
+ command: "/model anthropic/claude-opus-4-6 hello",
+ allowedModelKeys: ["anthropic/claude-opus-4-6", "openai/gpt-4o"],
+ sessionEntry,
provider: "openai",
model: "gpt-4o",
initialModelLabel: "openai/gpt-4o",
});
expect(sessionEntry.agentRuntimeOverride).toBeUndefined();
- expect(enqueueSystemEvent).toHaveBeenCalledWith(
- "Ignored unsupported runtime claude-cli for openai.",
- {
- sessionKey: "agent:main:dm:1",
- contextKey: "model-runtime:openai:claude-cli",
- },
+ expect(persisted.runtimeChange).toEqual({ kind: "clear" });
+ });
+
+ it("rejects model/runtime transactions that target an unsupported runtime", async () => {
+ vi.mocked(enqueueSystemEvent).mockClear();
+ const sessionEntry = createSessionEntry({
+ providerOverride: "anthropic",
+ modelOverride: "claude-opus-4-6",
+ modelOverrideSource: "user",
+ agentRuntimeOverride: "openclaw",
+ });
+ const { persisted } = await persistModelDirectiveForTest({
+ command: "/model openai/gpt-4o --runtime claude-cli hello",
+ allowedModelKeys: ["openai/gpt-4o"],
+ sessionEntry,
+ });
+
+ expect(persisted.errorText).toBe('Runtime "claude-cli" is not supported for openai.');
+ expect(sessionEntry).toMatchObject({
+ providerOverride: "anthropic",
+ modelOverride: "claude-opus-4-6",
+ modelOverrideSource: "user",
+ agentRuntimeOverride: "openclaw",
+ });
+ expect(enqueueSystemEvent).not.toHaveBeenCalled();
+ });
+
+ it("rejects unsupported mixed thinking before mutating the model/runtime transaction", async () => {
+ setOpenAiRuntimeScopedUltraProvider();
+ const sessionEntry = createSessionEntry({
+ providerOverride: "openai",
+ modelOverride: "gpt-5.6-sol",
+ modelOverrideSource: "user",
+ agentRuntimeOverride: "openclaw",
+ thinkingLevel: "high",
+ });
+ const initialSessionEntry = { ...sessionEntry };
+ const { persisted } = await persistModelDirectiveForTest({
+ command: "/model openai/gpt-5.6-luna --runtime codex /think ultra please solve",
+ allowedModelKeys: ["openai/gpt-5.6-luna"],
+ sessionEntry,
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ initialModelLabel: "openai/gpt-5.6-sol",
+ });
+
+ expect(persisted.errorText).toBe(
+ 'Thinking level "ultra" is not supported for openai/gpt-5.6-luna. Use one of: off, low, medium, high, max.',
);
+ expect(sessionEntry).toEqual(initialSessionEntry);
+ expect(enqueueSystemEvent).not.toHaveBeenCalled();
+ expect(queueMocks.refreshQueuedFollowupSession).not.toHaveBeenCalled();
+ });
+
+ it("persists an atomic model/runtime/thinking transaction when the runtime supports it", async () => {
+ setOpenAiRuntimeScopedUltraProvider();
+ const sessionEntry = createSessionEntry({ thinkingLevel: "high" });
+ const { persisted } = await persistModelDirectiveForTest({
+ command: "/model openai/gpt-5.6-luna --runtime openclaw /think ultra please solve",
+ allowedModelKeys: ["openai/gpt-5.6-luna"],
+ sessionEntry,
+ });
+
+ expect(persisted.errorText).toBeUndefined();
+ expect(sessionEntry).toMatchObject({
+ providerOverride: "openai",
+ modelOverride: "gpt-5.6-luna",
+ modelOverrideSource: "user",
+ agentRuntimeOverride: "openclaw",
+ thinkingLevel: "ultra",
+ });
});
it("persists alias-based numeric auth-profile overrides for mixed-content messages", async () => {
@@ -1516,6 +1647,57 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(sessionEntry.liveModelSwitchPending).toBe(true);
});
+ it("persists an explicit runtime with a directive-only model switch", async () => {
+ const sessionEntry = createSessionEntry();
+ const result = await handleDirectiveOnly(
+ createHandleParams({
+ directives: parseInlineDirectives("/model openai/gpt-4o --runtime openclaw"),
+ sessionEntry,
+ }),
+ );
+
+ expect(result?.text).toContain("Model set to openai/gpt-4o for this session.");
+ expect(result?.text).toContain("Runtime set to openclaw for this session.");
+ expect(sessionEntry).toMatchObject({
+ providerOverride: "openai",
+ modelOverride: "gpt-4o",
+ modelOverrideSource: "user",
+ agentRuntimeOverride: "openclaw",
+ });
+ });
+
+ it("rejects an invalid directive-only model/runtime transaction atomically", async () => {
+ const sessionEntry = createSessionEntry({
+ providerOverride: "anthropic",
+ modelOverride: "claude-opus-4-6",
+ modelOverrideSource: "user",
+ agentRuntimeOverride: "openclaw",
+ });
+ const initialSessionEntry = { ...sessionEntry };
+ const result = await handleDirectiveOnly(
+ createHandleParams({
+ directives: parseInlineDirectives("/model openai/gpt-4o --runtime claude-cli"),
+ sessionEntry,
+ }),
+ );
+
+ expect(result?.text).toBe('Runtime "claude-cli" is not supported for openai.');
+ expect(sessionEntry).toEqual(initialSessionEntry);
+ expect(queueMocks.refreshQueuedFollowupSession).not.toHaveBeenCalled();
+ });
+
+ it("preserves an explicit runtime pin when a model switch omits --runtime", async () => {
+ const sessionEntry = createSessionEntry({ agentRuntimeOverride: "codex" });
+ await handleDirectiveOnly(
+ createHandleParams({
+ directives: parseInlineDirectives("/model openai/gpt-4o"),
+ sessionEntry,
+ }),
+ );
+
+ expect(sessionEntry.agentRuntimeOverride).toBe("codex");
+ });
+
it("persists /model only on the targeted session entry", async () => {
const targetEntry = createSessionEntry();
const otherEntry = createSessionEntry();
@@ -1624,6 +1806,11 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
nextModelOverrideSource: "user",
nextAuthProfileId: undefined,
nextAuthProfileIdSource: undefined,
+ nextThinking: {
+ level: undefined,
+ catalog: allowedModelCatalog,
+ agentRuntime: "codex",
+ },
});
});
@@ -1827,6 +2014,11 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
nextModelOverrideSource: "user",
nextAuthProfileId: "anthropic:work",
nextAuthProfileIdSource: "user",
+ nextThinking: {
+ level: undefined,
+ catalog: allowedModelCatalog,
+ agentRuntime: "openclaw",
+ },
});
expect(enqueueSystemEvent).toHaveBeenCalledWith(
"Model switched to Opus (anthropic/claude-opus-4-6).",
@@ -1922,6 +2114,44 @@ describe("handleDirectiveOnly model persist behavior (fixes #1435)", () => {
expect(result?.text).toContain("Options: default, off, minimal, low, medium, adaptive, high.");
});
+ it("reports the effective thinking level for the pinned runtime", async () => {
+ setDirectiveTestProviders([
+ {
+ id: "openai",
+ label: "OpenAI",
+ auth: [],
+ resolveThinkingProfile: ({ agentRuntime }) => ({
+ levels: [
+ { id: "off" },
+ { id: "low" },
+ { id: "medium" },
+ { id: "high" },
+ { id: "max" },
+ ...(agentRuntime === "openclaw" ? ([{ id: "ultra" }] as const) : []),
+ ],
+ }),
+ },
+ ]);
+ const sessionEntry = createSessionEntry({
+ thinkingLevel: "ultra",
+ agentRuntimeOverride: "codex",
+ });
+
+ const result = await handleDirectiveOnly(
+ createHandleParams({
+ directives: parseInlineDirectives("/think"),
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ currentThinkLevel: "ultra",
+ sessionEntry,
+ }),
+ );
+
+ expect(result?.text).toContain("Current thinking level: max.");
+ expect(result?.text).toContain("Options: default, off, low, medium, high, max.");
+ expect(result?.text).not.toContain("ultra");
+ });
+
it("uses catalog reasoning metadata for provider-owned thinking levels", async () => {
setDirectiveTestProviders([
{
diff --git a/src/auto-reply/reply/directive-handling.model.ts b/src/auto-reply/reply/directive-handling.model.ts
index 3204f2137a34..bdc17c88d9f2 100644
--- a/src/auto-reply/reply/directive-handling.model.ts
+++ b/src/auto-reply/reply/directive-handling.model.ts
@@ -3,7 +3,6 @@ import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
-import { normalizeOptionalAgentRuntimeId } from "../../agents/agent-runtime-id.js";
import { resolveAuthStorePathForDisplay } from "../../agents/auth-profiles.js";
import type { AuthProfileCredential } from "../../agents/auth-profiles/types.js";
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
@@ -16,6 +15,7 @@ import {
resolveModelRefFromString,
} from "../../agents/model-selection.js";
import { buildAgentRuntimeAuthPlan } from "../../agents/runtime-plan/auth.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
import { getChannelPlugin } from "../../channels/plugins/index.js";
import type { SessionEntry } from "../../config/sessions.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -42,10 +42,14 @@ function isMissingAuthLabel(auth: { label: string; source: string }): boolean {
function resolveStatusHarnessRuntime(params: {
sessionEntry?: Pick;
defaultRuntime: string;
+ provider: string;
+ cfg: OpenClawConfig;
}): string {
- const sessionRuntime = normalizeOptionalAgentRuntimeId(
- params.sessionEntry?.agentRuntimeOverride ?? params.sessionEntry?.agentHarnessId,
- );
+ const sessionRuntime = resolveSessionRuntimeOverrideForProvider({
+ provider: params.provider,
+ entry: params.sessionEntry,
+ cfg: params.cfg,
+ });
if (sessionRuntime) {
return sessionRuntime;
}
@@ -83,6 +87,8 @@ async function resolveStatusAuthLabel(params: {
const harnessRuntime = resolveStatusHarnessRuntime({
sessionEntry: params.sessionEntry,
defaultRuntime: harnessPolicy.runtime,
+ provider,
+ cfg: params.cfg,
});
const auth = await resolveAuthLabel(
params.provider,
diff --git a/src/auto-reply/reply/directive-handling.persist.ts b/src/auto-reply/reply/directive-handling.persist.ts
index e0096e2dd75e..21b7e5287810 100644
--- a/src/auto-reply/reply/directive-handling.persist.ts
+++ b/src/auto-reply/reply/directive-handling.persist.ts
@@ -4,15 +4,11 @@ import {
resolveDefaultAgentId,
resolveSessionAgentId,
} from "../../agents/agent-scope.js";
-import { resolveCliRuntimeModelBackendBinding } from "../../agents/cli-backends.js";
import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
import type { ModelCatalogEntry } from "../../agents/model-catalog.js";
-import {
- modelKey,
- normalizeProviderId,
- type ModelAliasIndex,
-} from "../../agents/model-selection.js";
+import { modelKey, type ModelAliasIndex } from "../../agents/model-selection.js";
import { resolveContextConfigProviderForRuntime } from "../../agents/openai-routing.js";
+import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js";
import {
adoptPersistedSessionSnapshot,
sessionModelOverrideChangesApplied,
@@ -24,7 +20,15 @@ import { triggerSessionPatchHook } from "../../gateway/session-patch-hooks.js";
import { enqueueSystemEvent } from "../../infra/system-events.js";
import { applyTraceOverride, applyVerboseOverride } from "../../sessions/level-overrides.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
-import { isThinkingLevelSupported, resolveSupportedThinkingLevel } from "../thinking.js";
+import {
+ formatThinkingLevels,
+ isThinkingLevelSupported,
+ resolveSupportedThinkingLevel,
+} from "../thinking.js";
+import {
+ applyModelRuntimeDirective,
+ resolveModelRuntimeDirective,
+} from "./directive-handling.model-runtime.js";
import { resolveModelSelectionFromDirective } from "./directive-handling.model-selection.js";
import type { InlineDirectives } from "./directive-handling.parse.js";
import {
@@ -34,6 +38,7 @@ import {
} from "./directive-handling.shared.js";
import type { ElevatedLevel, ReasoningLevel, ThinkLevel } from "./directives.js";
import { resolveContextTokens } from "./model-selection.js";
+import { refreshQueuedFollowupSession } from "./queue.js";
import { persistReplySessionEntry } from "./session-entry-persistence.js";
export type PersistedThinkingLevelRemap = {
@@ -43,46 +48,6 @@ export type PersistedThinkingLevelRemap = {
model: string;
};
-const MODEL_RUNTIME_CLEAR_VALUES = new Set(["auto", "default"]);
-
-function resolveModelRuntimeOverride(params: {
- rawRuntime?: string;
- provider: string;
- cfg: OpenClawConfig;
-}):
- | { kind: "clear" }
- | { kind: "set"; runtime: string }
- | { kind: "invalid"; runtime: string }
- | undefined {
- const rawRuntime = params.rawRuntime?.trim();
- if (!rawRuntime) {
- return undefined;
- }
-
- const runtime = normalizeProviderId(rawRuntime);
- if (MODEL_RUNTIME_CLEAR_VALUES.has(runtime)) {
- return { kind: "clear" };
- }
- if (runtime === "openclaw") {
- return { kind: "set", runtime: "openclaw" };
- }
- if (normalizeProviderId(params.provider) === "openai" && runtime === "codex") {
- return { kind: "set", runtime: "codex" };
- }
-
- const provider = normalizeProviderId(params.provider);
- const backend = resolveCliRuntimeModelBackendBinding({
- config: params.cfg,
- provider,
- runtime,
- });
- if (backend) {
- return { kind: "set", runtime: backend.runtime };
- }
-
- return { kind: "invalid", runtime: rawRuntime };
-}
-
export async function persistInlineDirectives(params: {
directives: InlineDirectives;
effectiveModelDirective?: string;
@@ -117,6 +82,8 @@ export async function persistInlineDirectives(params: {
contextTokens: number;
sessionChangesApplied: boolean;
thinkingRemap?: PersistedThinkingLevelRemap;
+ errorText?: string;
+ runtimeChange?: { kind: "clear" } | { kind: "set"; runtime: string };
}> {
const {
directives,
@@ -166,8 +133,68 @@ export async function persistInlineDirectives(params: {
? resolveSessionAgentId({ sessionKey, config: cfg })
: resolveDefaultAgentId(cfg);
const agentDir = resolveAgentDir(cfg, activeAgentId) ?? params.agentDir;
+ const modelDirective =
+ directives.hasModelDirective && params.effectiveModelDirective
+ ? params.effectiveModelDirective
+ : undefined;
+ const modelResolution = modelDirective
+ ? resolveModelSelectionFromDirective({
+ directives: {
+ ...directives,
+ hasModelDirective: true,
+ rawModelDirective: modelDirective,
+ },
+ cfg,
+ agentDir,
+ defaultProvider,
+ defaultModel,
+ aliasIndex,
+ allowedModelKeys,
+ allowedModelCatalog: params.modelCatalog ?? [],
+ provider,
+ })
+ : undefined;
+ const modelRuntimeResolution = modelResolution?.modelSelection
+ ? resolveModelRuntimeDirective({
+ rawRuntime: directives.rawModelRuntime,
+ provider: modelResolution.modelSelection.provider,
+ cfg,
+ sessionEntry,
+ })
+ : ({ kind: "unchanged" } as const);
+ let thinkingErrorText: string | undefined;
+ if (directives.hasThinkDirective && directives.thinkLevel) {
+ const resolvedProvider = modelResolution?.modelSelection?.provider ?? provider;
+ const resolvedModel = modelResolution?.modelSelection?.model ?? model;
+ const prospectiveSessionEntry = { ...sessionEntry };
+ applyModelRuntimeDirective(prospectiveSessionEntry, modelRuntimeResolution);
+ const prospectiveThinkingRuntime = resolveEffectiveAgentRuntime({
+ cfg,
+ provider: resolvedProvider,
+ modelId: resolvedModel,
+ agentId: activeAgentId,
+ sessionKey,
+ sessionEntry: prospectiveSessionEntry,
+ });
+ if (
+ !isThinkingLevelSupported({
+ provider: resolvedProvider,
+ model: resolvedModel,
+ level: directives.thinkLevel,
+ catalog: thinkingCatalog,
+ agentRuntime: prospectiveThinkingRuntime,
+ })
+ ) {
+ thinkingErrorText = `Thinking level "${directives.thinkLevel}" is not supported for ${resolvedProvider}/${resolvedModel}. Use one of: ${formatThinkingLevels(resolvedProvider, resolvedModel, ", ", thinkingCatalog, prospectiveThinkingRuntime)}.`;
+ }
+ }
+ const errorText =
+ modelResolution?.errorText ??
+ (modelRuntimeResolution.kind === "invalid" ? modelRuntimeResolution.errorText : undefined) ??
+ thinkingErrorText;
+ let modelRuntimeApplied = false;
- if (sessionEntry && sessionStore && sessionKey) {
+ if (!errorText && sessionEntry && sessionStore && sessionKey) {
const initialSessionEntry = { ...sessionEntry };
let appliedSessionEntry = sessionEntry;
const prevElevatedLevel =
@@ -260,106 +287,72 @@ export async function persistInlineDirectives(params: {
}
}
- const modelDirective =
- directives.hasModelDirective && params.effectiveModelDirective
- ? params.effectiveModelDirective
- : undefined;
let modelUpdated = false;
let modelApplied = true;
- let modelRuntimeEvent: { contextKey: string; text: string } | undefined;
let modelSwitchEvent: { alias?: string; label: string } | undefined;
- if (modelDirective) {
- const modelResolution = resolveModelSelectionFromDirective({
- directives: {
- ...directives,
- hasModelDirective: true,
- rawModelDirective: modelDirective,
- },
- cfg,
- agentDir,
- defaultProvider,
- defaultModel,
- aliasIndex,
- allowedModelKeys,
- allowedModelCatalog: params.modelCatalog ?? [],
- provider,
+ if (modelDirective && modelResolution?.modelSelection) {
+ const appliedModelOverride = applyModelOverrideToSessionEntry({
+ entry: sessionEntry,
+ selection: modelResolution.modelSelection,
+ profileOverride: modelResolution.profileOverride,
+ markLiveSwitchPending: params.markLiveSwitchPending,
});
- if (modelResolution.modelSelection) {
- const appliedModelOverride = applyModelOverrideToSessionEntry({
- entry: sessionEntry,
- selection: modelResolution.modelSelection,
- profileOverride: modelResolution.profileOverride,
- markLiveSwitchPending: params.markLiveSwitchPending,
+ const appliedRuntimeOverride = applyModelRuntimeDirective(
+ sessionEntry,
+ modelRuntimeResolution,
+ );
+ modelUpdated = appliedModelOverride.updated || appliedRuntimeOverride.updated;
+ provider = modelResolution.modelSelection.provider;
+ model = modelResolution.modelSelection.model;
+ const thinkingRuntime = resolveEffectiveAgentRuntime({
+ cfg,
+ provider,
+ modelId: model,
+ agentId: activeAgentId,
+ sessionKey,
+ sessionEntry,
+ });
+ const currentThinkingLevel = sessionEntry.thinkingLevel as ThinkLevel | undefined;
+ if (
+ currentThinkingLevel &&
+ !directives.hasThinkDirective &&
+ !isThinkingLevelSupported({
+ provider,
+ model,
+ level: currentThinkingLevel,
+ catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
+ })
+ ) {
+ const remappedThinkingLevel = resolveSupportedThinkingLevel({
+ provider,
+ model,
+ level: currentThinkingLevel,
+ catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
});
- const runtimeOverride = resolveModelRuntimeOverride({
- rawRuntime: directives.rawModelRuntime,
- provider: modelResolution.modelSelection.provider,
- cfg,
- });
- if (runtimeOverride?.kind === "clear") {
- if (sessionEntry.agentRuntimeOverride) {
- delete sessionEntry.agentRuntimeOverride;
- }
- } else if (runtimeOverride?.kind === "set") {
- if (sessionEntry.agentRuntimeOverride) {
- delete sessionEntry.agentRuntimeOverride;
- }
- modelRuntimeEvent = {
- text: `Ignored session runtime ${runtimeOverride.runtime}; configure provider or model runtime policy instead.`,
- contextKey: `model-runtime:${modelResolution.modelSelection.provider}:${runtimeOverride.runtime}:ignored-session-runtime`,
- };
- } else if (runtimeOverride?.kind === "invalid") {
- if (sessionEntry.agentRuntimeOverride) {
- delete sessionEntry.agentRuntimeOverride;
- }
- modelRuntimeEvent = {
- text: `Ignored unsupported runtime ${runtimeOverride.runtime} for ${modelResolution.modelSelection.provider}.`,
- contextKey: `model-runtime:${modelResolution.modelSelection.provider}:${runtimeOverride.runtime}`,
- };
- }
- modelUpdated = appliedModelOverride.updated;
- provider = modelResolution.modelSelection.provider;
- model = modelResolution.modelSelection.model;
- const currentThinkingLevel = sessionEntry.thinkingLevel as ThinkLevel | undefined;
- if (
- currentThinkingLevel &&
- !directives.hasThinkDirective &&
- !isThinkingLevelSupported({
+ if (remappedThinkingLevel !== currentThinkingLevel) {
+ sessionEntry.thinkingLevel = remappedThinkingLevel;
+ thinkingRemap = {
+ from: currentThinkingLevel,
+ to: remappedThinkingLevel,
provider,
model,
- level: currentThinkingLevel,
- catalog: thinkingCatalog,
- })
- ) {
- const remappedThinkingLevel = resolveSupportedThinkingLevel({
- provider,
- model,
- level: currentThinkingLevel,
- catalog: thinkingCatalog,
- });
- if (remappedThinkingLevel !== currentThinkingLevel) {
- sessionEntry.thinkingLevel = remappedThinkingLevel;
- thinkingRemap = {
- from: currentThinkingLevel,
- to: remappedThinkingLevel,
- provider,
- model,
- };
- }
- }
- const nextLabel = `${provider}/${model}`;
- if (nextLabel !== initialModelLabel) {
- modelSwitchEvent = {
- label: nextLabel,
- ...(modelResolution.modelSelection.alias
- ? { alias: modelResolution.modelSelection.alias }
- : {}),
};
}
- // Explicit model selections must still perform the atomic persisted
- // winner check when their value matches the local snapshot.
- updated = true;
}
+ const nextLabel = `${provider}/${model}`;
+ if (nextLabel !== initialModelLabel) {
+ modelSwitchEvent = {
+ label: nextLabel,
+ ...(modelResolution.modelSelection.alias
+ ? { alias: modelResolution.modelSelection.alias }
+ : {}),
+ };
+ }
+ // Explicit model selections must still perform the atomic persisted
+ // winner check when their value matches the local snapshot.
+ updated = true;
}
if (directives.hasQueueDirective && directives.queueReset) {
delete sessionEntry.queueMode;
@@ -432,6 +425,26 @@ export async function persistInlineDirectives(params: {
sessionKey,
patch: { key: sessionKey, model: modelDirective },
});
+ refreshQueuedFollowupSession({
+ key: sessionKey,
+ nextProvider: provider,
+ nextModel: model,
+ nextModelOverrideSource: "user",
+ nextAuthProfileId: appliedSessionEntry.authProfileOverride,
+ nextAuthProfileIdSource: appliedSessionEntry.authProfileOverrideSource,
+ nextThinking: {
+ level: appliedSessionEntry.thinkingLevel,
+ catalog: thinkingCatalog,
+ agentRuntime: resolveEffectiveAgentRuntime({
+ cfg,
+ provider,
+ modelId: model,
+ agentId: activeAgentId,
+ sessionKey,
+ sessionEntry: appliedSessionEntry,
+ }),
+ },
+ });
}
if (sessionChangesApplied) {
enqueueModeSwitchEvents({
@@ -443,12 +456,9 @@ export async function persistInlineDirectives(params: {
});
}
}
- if (modelRuntimeEvent && modelApplied) {
- enqueueSystemEvent(modelRuntimeEvent.text, {
- sessionKey,
- contextKey: modelRuntimeEvent.contextKey,
- });
- }
+ modelRuntimeApplied =
+ modelApplied &&
+ (modelRuntimeResolution.kind === "clear" || modelRuntimeResolution.kind === "set");
if (modelSwitchEvent && modelApplied) {
enqueueSystemEvent(formatModelSwitchEvent(modelSwitchEvent.label, modelSwitchEvent.alias), {
sessionKey,
@@ -464,6 +474,12 @@ export async function persistInlineDirectives(params: {
provider,
model,
thinkingRemap,
+ errorText,
+ runtimeChange:
+ modelRuntimeApplied &&
+ (modelRuntimeResolution.kind === "clear" || modelRuntimeResolution.kind === "set")
+ ? modelRuntimeResolution
+ : undefined,
sessionChangesApplied,
contextTokens: resolveContextTokens({
cfg,
diff --git a/src/auto-reply/reply/dispatch-from-config.ts b/src/auto-reply/reply/dispatch-from-config.ts
index 8ae30e5dea07..dbae6b2496c1 100644
--- a/src/auto-reply/reply/dispatch-from-config.ts
+++ b/src/auto-reply/reply/dispatch-from-config.ts
@@ -31,6 +31,7 @@ import {
resolveModelRefFromString,
type ModelAliasIndex,
} from "../../agents/model-selection.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
import {
isSubagentEnvelopeSession,
resolveSubagentCapabilityStore,
@@ -123,7 +124,6 @@ import {
} from "../reply-payload.js";
import type { FinalizedMsgContext } from "../templating.js";
import { normalizeVerboseLevel } from "../thinking.js";
-import { resolveSessionRuntimeOverrideForProvider } from "./agent-runner-execution.js";
import {
takeCommandSessionMetadataChanges,
type CommandSessionMetadataChange,
diff --git a/src/auto-reply/reply/followup-runner.test.ts b/src/auto-reply/reply/followup-runner.test.ts
index 94d6ab779c1a..593d74feae83 100644
--- a/src/auto-reply/reply/followup-runner.test.ts
+++ b/src/auto-reply/reply/followup-runner.test.ts
@@ -2178,6 +2178,55 @@ describe("createFollowupRunner runtime config", () => {
expect(lifecyclePhases).toEqual(["start", "start", "finishing", "end"]);
});
+ it("revalidates immutable Ultra for embedded and CLI followup fallback candidates", async () => {
+ const runtimeConfig: OpenClawConfig = {
+ agents: {
+ defaults: {
+ cliBackends: {
+ "claude-cli": { command: "claude" },
+ },
+ models: {
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } },
+ "anthropic/claude-opus-4-7": { agentRuntime: { id: "claude-cli" } },
+ },
+ },
+ },
+ };
+ runWithModelFallbackMock.mockImplementationOnce(
+ async (params: { run: (provider: string, model: string) => Promise }) => {
+ await params.run("openai", "gpt-5.6-sol");
+ return {
+ result: await params.run("anthropic", "claude-opus-4-7"),
+ provider: "anthropic",
+ model: "claude-opus-4-7",
+ };
+ },
+ );
+ runEmbeddedAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} });
+ runCliAgentMock.mockResolvedValueOnce({ payloads: [], meta: {} });
+ const queued = createQueuedRun({
+ run: {
+ config: runtimeConfig,
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ thinkLevel: "ultra",
+ },
+ });
+ const runner = createFollowupRunner({
+ typing: createMockTypingController(),
+ typingMode: "instant",
+ defaultModel: "openai/gpt-5.6-sol",
+ });
+
+ await runner(queued);
+
+ expect(requireLastMockCallArg(runEmbeddedAgentMock, "run embedded agent").thinkLevel).toBe(
+ "ultra",
+ );
+ expect(requireLastMockCallArg(runCliAgentMock, "run cli agent").thinkLevel).toBe("max");
+ expect(queued.run.thinkLevel).toBe("ultra");
+ });
+
it("delivers an exhausted embedded followup as a failed lifecycle", async () => {
const realAgentEvents = await vi.importActual(
"../../infra/agent-events.js",
diff --git a/src/auto-reply/reply/followup-runner.ts b/src/auto-reply/reply/followup-runner.ts
index 560b2c815ffb..065855dbd97e 100644
--- a/src/auto-reply/reply/followup-runner.ts
+++ b/src/auto-reply/reply/followup-runner.ts
@@ -38,6 +38,8 @@ import {
buildAgentRuntimeDeliveryPlan,
buildAgentRuntimeOutcomePlan,
} from "../../agents/runtime-plan/build.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
+import { resolveCandidateThinkingLevel } from "../../agents/thinking-runtime.js";
import type { SessionEntry } from "../../config/sessions.js";
import { loadSessionEntry, updateSessionEntry } from "../../config/sessions/session-accessor.js";
import type { TypingMode } from "../../config/types.js";
@@ -78,7 +80,6 @@ import {
buildCommandOutputFromToolResultEvent,
buildPreflightCompactionFailureText,
resolveRunAfterAutoFallbackPrimaryProbeRecheck,
- resolveSessionRuntimeOverrideForProvider,
} from "./agent-runner-execution.js";
import { runPreflightCompactionIfNeeded } from "./agent-runner-memory.js";
import { appendUsageLine, resolveResponseUsageLine } from "./agent-runner-usage-line.js";
@@ -1040,6 +1041,15 @@ export function createFollowupRunner(params: {
const suppressAssistantErrorPersistenceForCandidate =
assistantErrorPersistedAcrossFallback;
const candidateRun = resolveRunForFallbackCandidate(provider, model);
+ const candidateThinkLevel = resolveCandidateThinkingLevel({
+ cfg: runtimeConfig,
+ provider,
+ modelId: model,
+ level: run.thinkLevel,
+ agentId: run.agentId,
+ sessionKey: run.runtimePolicySessionKey ?? replySessionKey,
+ sessionEntry: activeSessionEntry,
+ });
const candidateFastMode = resolveRunFastModeForFallbackCandidate({
run: candidateRun,
config: runtimeConfig,
@@ -1253,7 +1263,7 @@ export function createFollowupRunner(params: {
...resolveRunAuthProfile(candidateRun, cliExecutionProvider, {
config: runtimeConfig,
}),
- thinkLevel: run.thinkLevel,
+ thinkLevel: candidateThinkLevel,
fastMode: candidateFastMode.fastMode,
fastModeStartedAtMs,
fastModeAutoOnSeconds: candidateFastMode.fastModeAutoOnSeconds,
@@ -1387,7 +1397,7 @@ export function createFollowupRunner(params: {
provider,
model,
...selectedAuthProfile,
- thinkLevel: run.thinkLevel,
+ thinkLevel: candidateThinkLevel,
fastMode: candidateFastMode.fastMode,
fastModeStartedAtMs,
fastModeAutoOnSeconds: candidateFastMode.fastModeAutoOnSeconds,
diff --git a/src/auto-reply/reply/get-reply-directives-apply.test.ts b/src/auto-reply/reply/get-reply-directives-apply.test.ts
index 38741bc36818..986d1e8ed312 100644
--- a/src/auto-reply/reply/get-reply-directives-apply.test.ts
+++ b/src/auto-reply/reply/get-reply-directives-apply.test.ts
@@ -137,4 +137,82 @@ describe("applyInlineDirectiveOverrides", () => {
});
expect(typing.cleanup).toHaveBeenCalledOnce();
});
+
+ it("stops a mixed inline turn when final thinking validation fails", async () => {
+ const errorText =
+ 'Thinking level "ultra" is not supported for openai/gpt-5.6-luna. Use one of: off, low, medium, high, max.';
+ const directives = parseInlineDirectives("/think ultra please solve");
+ mocks.fastLane.mockResolvedValue({
+ directiveAck: { text: errorText },
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ sessionChangesApplied: true,
+ });
+ mocks.persist.mockResolvedValue({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ contextTokens: 372_000,
+ sessionChangesApplied: true,
+ errorText,
+ });
+ const typing = {
+ onReplyStart: async () => {},
+ startTypingLoop: async () => {},
+ startTypingOnText: async () => {},
+ refreshTypingTtl: () => {},
+ isActive: () => false,
+ markRunComplete: () => {},
+ markDispatchIdle: () => {},
+ cleanup: vi.fn(),
+ };
+ const sessionEntry = { sessionId: "session-1", updatedAt: 1 };
+
+ const result = await applyInlineDirectiveOverrides({
+ ctx: buildTestCtx({ Body: "/think ultra please solve", CommandAuthorized: true }),
+ cfg: {},
+ agentId: "main",
+ agentDir: "/tmp/agent",
+ workspaceDir: "/tmp/workspace",
+ agentCfg: {},
+ sessionEntry,
+ sessionStore: { "agent:main:main": sessionEntry },
+ sessionKey: "agent:main:main",
+ sessionScope: undefined,
+ isGroup: false,
+ allowTextCommands: true,
+ command: {
+ surface: "webchat",
+ channel: "webchat",
+ ownerList: [],
+ senderIsOwner: true,
+ isAuthorizedSender: true,
+ rawBodyNormalized: "/think ultra please solve",
+ commandBodyNormalized: "/think ultra please solve",
+ },
+ directives,
+ messageProviderKey: "webchat",
+ elevatedEnabled: true,
+ elevatedAllowed: true,
+ elevatedFailures: [],
+ defaultProvider: "openai",
+ defaultModel: "gpt-5.6-luna",
+ aliasIndex: { byAlias: new Map(), byKey: new Map() },
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ modelState: createFastTestModelSelectionState({
+ agentCfg: {},
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ }),
+ initialModelLabel: "openai/gpt-5.6-luna",
+ formatModelSwitchEvent: (label) => label,
+ resolvedElevatedLevel: "off",
+ defaultActivation: () => "always",
+ contextTokens: 372_000,
+ typing,
+ });
+
+ expect(result).toEqual({ kind: "reply", reply: { text: errorText } });
+ expect(typing.cleanup).toHaveBeenCalledOnce();
+ });
});
diff --git a/src/auto-reply/reply/get-reply-directives-apply.ts b/src/auto-reply/reply/get-reply-directives-apply.ts
index c8a937476b24..6f7fe298ac24 100644
--- a/src/auto-reply/reply/get-reply-directives-apply.ts
+++ b/src/auto-reply/reply/get-reply-directives-apply.ts
@@ -306,6 +306,10 @@ export async function applyInlineDirectiveOverrides(params: {
model,
markLiveSwitchPending: true,
});
+ if (persisted.errorText) {
+ typing.cleanup();
+ return { kind: "reply", reply: { text: persisted.errorText } };
+ }
if (!persisted.sessionChangesApplied) {
typing.cleanup();
return {
@@ -322,6 +326,11 @@ export async function applyInlineDirectiveOverrides(params: {
modelSelection.isDefault
? `Model reset to default (${labelWithAlias}).`
: `Model set to ${labelWithAlias} for this session.`,
+ persisted.runtimeChange?.kind === "clear"
+ ? "Runtime reset to configured policy."
+ : persisted.runtimeChange?.kind === "set"
+ ? `Runtime set to ${persisted.runtimeChange.runtime} for this session.`
+ : undefined,
modelResolution.profileOverride
? `Auth profile set to ${modelResolution.profileOverride}.`
: undefined,
@@ -461,6 +470,10 @@ export async function applyInlineDirectiveOverrides(params: {
provider = persisted.provider;
model = persisted.model;
contextTokens = persisted.contextTokens;
+ if (persisted.errorText) {
+ typing.cleanup();
+ return { kind: "reply", reply: { text: persisted.errorText } };
+ }
if (!persisted.sessionChangesApplied) {
typing.cleanup();
return {
diff --git a/src/auto-reply/reply/get-reply-directives.target-session.test.ts b/src/auto-reply/reply/get-reply-directives.target-session.test.ts
index 548a2b058af0..85c4845cc315 100644
--- a/src/auto-reply/reply/get-reply-directives.target-session.test.ts
+++ b/src/auto-reply/reply/get-reply-directives.target-session.test.ts
@@ -38,6 +38,33 @@ function makeTypingController() {
function parseInlineDirectivesForTest(body: string) {
const normalized = body.trim();
+ const modelDirective = normalized.match(/(?:^|\n)\/model\s+(\S+)/)?.[1];
+ if (modelDirective) {
+ return {
+ cleaned: normalized.replace(/(?:^|\n)\/model\s+\S+/, "").trim(),
+ hasThinkDirective: false,
+ hasVerboseDirective: false,
+ hasTraceDirective: false,
+ traceLevel: undefined,
+ rawTraceLevel: undefined,
+ hasFastDirective: false,
+ hasReasoningDirective: false,
+ hasElevatedDirective: false,
+ hasExecDirective: false,
+ hasModelDirective: true,
+ hasQueueDirective: false,
+ hasStatusDirective: false,
+ queueReset: false,
+ thinkLevel: undefined,
+ verboseLevel: undefined,
+ fastMode: undefined,
+ reasoningLevel: undefined,
+ elevatedLevel: undefined,
+ rawElevatedLevel: undefined,
+ rawModelDirective: modelDirective,
+ execSecurity: undefined,
+ };
+ }
if (normalized === "/reasoning stream") {
return {
cleaned: "",
@@ -143,8 +170,10 @@ function expectContinueResult(
}
async function resolveHelloWithModelDefaults(params: {
- defaultThinking: "off" | "low";
+ defaultThinking: "off" | "low" | "medium";
+ defaultThinkingByModel?: Record;
defaultReasoning: "on";
+ cfg?: Parameters[0]["cfg"];
body?: string;
sessionEntry?: SessionEntry;
agentCfg?: { reasoningDefault?: "off" | "on" | "stream" };
@@ -160,7 +189,11 @@ async function resolveHelloWithModelDefaults(params: {
opts?: Parameters[0]["opts"];
modelError?: unknown;
}) {
- const resolveDefaultThinkingLevel = vi.fn(async () => params.defaultThinking);
+ const resolveDefaultThinkingLevel = vi.fn(
+ async (selection?: { model?: string }) =>
+ (selection?.model ? params.defaultThinkingByModel?.[selection.model] : undefined) ??
+ params.defaultThinking,
+ );
const resolveDefaultReasoningLevel = vi.fn(async () => params.defaultReasoning);
mocks.listAgentEntries.mockReturnValue(params.agentEntries ?? []);
if (params.modelError) {
@@ -185,7 +218,7 @@ async function resolveHelloWithModelDefaults(params: {
CommandBody: params.body ?? "hello",
...params.ctx,
}),
- cfg: {},
+ cfg: params.cfg ?? {},
agentId: "main",
agentDir: "/tmp/main-agent",
workspaceDir: "/tmp",
@@ -219,7 +252,7 @@ async function resolveHelloWithModelDefaults(params: {
skillFilter: undefined,
});
- return { result, resolveDefaultReasoningLevel, typing };
+ return { result, resolveDefaultThinkingLevel, resolveDefaultReasoningLevel, typing };
}
vi.mock("../../agents/agent-scope.js", () => ({
@@ -238,6 +271,20 @@ vi.mock("../../agents/sandbox/runtime-status.js", () => ({
resolveSandboxRuntimeStatus: vi.fn(() => ({ sandboxed: false })),
}));
+vi.mock("../../agents/thinking-runtime.js", () => ({
+ resolveEffectiveAgentRuntime: ({
+ cfg,
+ provider,
+ modelId,
+ }: {
+ cfg: Parameters[0]["cfg"];
+ provider: string;
+ modelId: string;
+ }) =>
+ cfg.agents?.defaults?.models?.[`${provider}/${modelId}`]?.agentRuntime?.id ??
+ (provider === "openai" ? "codex" : "openclaw"),
+}));
+
vi.mock("../../routing/session-key.js", () => ({
normalizeAgentId: (value: string) => value,
}));
@@ -277,6 +324,10 @@ vi.mock("./get-reply-directives-apply.js", () => ({
mocks.applyInlineDirectiveOverrides(...args),
}));
+vi.mock("./runtime-policy-session-key.js", () => ({
+ resolveRuntimePolicySessionKey: ({ sessionKey }: { sessionKey?: string }) => sessionKey,
+}));
+
vi.mock("./get-reply-exec-overrides.js", () => ({
resolveReplyExecOverrides: (...args: unknown[]) => mocks.resolveReplyExecOverrides(...args),
}));
@@ -436,6 +487,87 @@ describe("resolveReplyDirectives", () => {
});
});
+ it.each([
+ ["gpt-5.6-terra", "medium"],
+ ["gpt-5.6-luna", "medium"],
+ ] as const)(
+ "resolves the %s thinking default after a mixed model switch",
+ async (targetModel, expectedThinking) => {
+ mocks.applyInlineDirectiveOverrides.mockImplementationOnce(async (params) => ({
+ kind: "continue",
+ directives: params.directives,
+ provider: "openai",
+ model: targetModel,
+ contextTokens: params.contextTokens,
+ }));
+
+ const { result, resolveDefaultThinkingLevel } = await resolveHelloWithModelDefaults({
+ body: `reply to this\n/model openai/${targetModel}`,
+ commandAuthorized: true,
+ defaultThinking: "low",
+ defaultThinkingByModel: {
+ "gpt-5.6-sol": "low",
+ [targetModel]: expectedThinking,
+ },
+ defaultReasoning: "on",
+ selectedProvider: "openai",
+ selectedModel: "gpt-5.6-sol",
+ cfg: {
+ agents: {
+ defaults: {
+ models: {
+ [`openai/${targetModel}`]: { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ },
+ });
+
+ expectContinueResult(result, { resolvedThinkLevel: expectedThinking });
+ expect(resolveDefaultThinkingLevel).toHaveBeenLastCalledWith({
+ provider: "openai",
+ model: targetModel,
+ agentRuntime: "codex",
+ });
+ },
+ );
+
+ it("uses the Sol default on the switching turn from a non-reasoning model", async () => {
+ mocks.applyInlineDirectiveOverrides.mockImplementationOnce(async (params) => ({
+ kind: "continue",
+ directives: params.directives,
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ contextTokens: params.contextTokens,
+ }));
+
+ const { result, resolveDefaultThinkingLevel } = await resolveHelloWithModelDefaults({
+ body: "reply to this\n/model openai/gpt-5.6-sol",
+ commandAuthorized: true,
+ defaultThinking: "off",
+ defaultThinkingByModel: { "gpt-5.6-sol": "low" },
+ defaultReasoning: "on",
+ selectedProvider: "openai",
+ selectedModel: "gpt-4o-mini",
+ cfg: {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } },
+ },
+ },
+ },
+ },
+ });
+
+ expectContinueResult(result, { resolvedThinkLevel: "low" });
+ expect(resolveDefaultThinkingLevel).toHaveBeenLastCalledWith({
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ agentRuntime: "openclaw",
+ });
+ });
+
it("prefers the target session entry from sessionStore for directive state", async () => {
const wrapperSessionEntry = makeSessionEntry({
sessionId: "wrapper-session",
diff --git a/src/auto-reply/reply/get-reply-directives.ts b/src/auto-reply/reply/get-reply-directives.ts
index 3e10e9e8d32a..dae633b13243 100644
--- a/src/auto-reply/reply/get-reply-directives.ts
+++ b/src/auto-reply/reply/get-reply-directives.ts
@@ -8,6 +8,7 @@ import { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
import { resolveFastModeState } from "../../agents/fast-mode.js";
import { type ModelAliasIndex, resolveModelRefFromString } from "../../agents/model-selection.js";
import { resolveSandboxRuntimeStatus } from "../../agents/sandbox/runtime-status.js";
+import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js";
import type { SessionEntry } from "../../config/sessions.js";
import { isSessionWorkStartInvalidatedError } from "../../config/sessions/lifecycle.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
@@ -559,39 +560,6 @@ export async function resolveReplyDirectives(params: {
}
provider = modelState.provider;
model = modelState.model;
- const resolvedThinkLevelWithDefault =
- resolvedThinkLevel ??
- (await modelState.resolveDefaultThinkingLevel()) ??
- configuredThinkingDefault;
-
- const thinkingExplicitlySet =
- thinkingLevelOverride !== undefined ||
- directives.thinkLevel !== undefined ||
- sessionThinkLevel !== undefined ||
- configuredThinkingDefault !== undefined ||
- modelState.hasConfiguredThinkingDefault === true;
-
- // When neither directive nor session nor agent set reasoning, default to model capability
- // (e.g. OpenRouter with reasoning: true). Skip model default when thinking is active
- // or when thinking was explicitly disabled.
- const hasAgentReasoningDefault =
- (agentEntry?.reasoningDefault !== undefined && agentEntry?.reasoningDefault !== null) ||
- (agentCfg?.reasoningDefault !== undefined && agentCfg?.reasoningDefault !== null);
- const reasoningExplicitlySet =
- directives.reasoningLevel !== undefined ||
- unauthorizedReasoningDirectiveAttempt ||
- blockedSessionReasoningLevel ||
- (sessionReasoningLevel !== undefined && sessionReasoningLevel !== null) ||
- hasAgentReasoningDefault;
- const thinkingActive = resolvedThinkLevelWithDefault !== "off";
- if (
- !reasoningExplicitlySet &&
- resolvedReasoningLevel === "off" &&
- !thinkingActive &&
- !thinkingExplicitlySet
- ) {
- resolvedReasoningLevel = await modelState.resolveDefaultReasoningLevel();
- }
let contextTokens = useFastReplyRuntime
? (agentCfg?.contextTokens ?? DEFAULT_CONTEXT_TOKENS)
@@ -658,6 +626,51 @@ export async function resolveReplyDirectives(params: {
provider = applyResult.provider;
model = applyResult.model;
contextTokens = applyResult.contextTokens;
+ const thinkingRuntime = resolveEffectiveAgentRuntime({
+ cfg,
+ provider,
+ modelId: model,
+ agentId,
+ sessionKey: resolveRuntimePolicySessionKey({ cfg, ctx, sessionKey }),
+ sessionEntry: targetSessionEntry,
+ });
+ const resolvedThinkLevelWithDefault =
+ resolvedThinkLevel ??
+ (await modelState.resolveDefaultThinkingLevel({
+ provider,
+ model,
+ agentRuntime: thinkingRuntime,
+ })) ??
+ configuredThinkingDefault;
+
+ const thinkingExplicitlySet =
+ thinkingLevelOverride !== undefined ||
+ directives.thinkLevel !== undefined ||
+ sessionThinkLevel !== undefined ||
+ configuredThinkingDefault !== undefined ||
+ modelState.hasConfiguredThinkingDefault === true;
+
+ // When neither directive nor session nor agent set reasoning, default to model capability
+ // (e.g. OpenRouter with reasoning: true). Skip model default when thinking is active
+ // or when thinking was explicitly disabled.
+ const hasAgentReasoningDefault =
+ (agentEntry?.reasoningDefault !== undefined && agentEntry?.reasoningDefault !== null) ||
+ (agentCfg?.reasoningDefault !== undefined && agentCfg?.reasoningDefault !== null);
+ const reasoningExplicitlySet =
+ directives.reasoningLevel !== undefined ||
+ unauthorizedReasoningDirectiveAttempt ||
+ blockedSessionReasoningLevel ||
+ (sessionReasoningLevel !== undefined && sessionReasoningLevel !== null) ||
+ hasAgentReasoningDefault;
+ const thinkingActive = resolvedThinkLevelWithDefault !== "off";
+ if (
+ !reasoningExplicitlySet &&
+ resolvedReasoningLevel === "off" &&
+ !thinkingActive &&
+ !thinkingExplicitlySet
+ ) {
+ resolvedReasoningLevel = await modelState.resolveDefaultReasoningLevel();
+ }
const { directiveAck, perMessageQueueMode, perMessageQueueOptions } = applyResult;
const resolvedFastModeState = resolveFastModeState({
cfg,
diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts
index d89c4f478e9a..7fe3b5342788 100644
--- a/src/auto-reply/reply/get-reply-run.ts
+++ b/src/auto-reply/reply/get-reply-run.ts
@@ -19,6 +19,7 @@ import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js";
import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js";
import type { SilentReplyPromptMode } from "../../agents/system-prompt.types.js";
+import { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js";
import { normalizeChatType } from "../../channels/chat-type.js";
import { updateAmbientTranscriptWatermark } from "../../config/sessions/ambient-transcript-watermark.js";
import { resolveGroupSessionKey } from "../../config/sessions/group.js";
@@ -606,6 +607,14 @@ export async function runPreparedReply(
cfg,
isFastTestEnv: process.env.OPENCLAW_TEST_FAST === "1",
});
+ const thinkingRuntime = resolveEffectiveAgentRuntime({
+ cfg,
+ provider,
+ modelId: model,
+ agentId,
+ sessionKey: runtimePolicySessionKey,
+ sessionEntry,
+ });
const fullAccessState = resolveEmbeddedFullAccessState({
execElevated: {
enabled: elevatedEnabled,
@@ -910,7 +919,13 @@ export async function runPreparedReply(
: undefined;
if (
maybeLevel &&
- isThinkingLevelSupported({ provider, model, level: maybeLevel, catalog: thinkingCatalog })
+ isThinkingLevelSupported({
+ provider,
+ model,
+ level: maybeLevel,
+ catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
+ })
) {
resolvedThinkLevel = maybeLevel;
prefixedBodyBase = parts.slice(1).join(" ").trim();
@@ -1011,6 +1026,7 @@ export async function runPreparedReply(
model,
level: resolvedThinkLevel,
catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
});
const shouldHydrateThinkingCatalog =
!thinkingLevelSupported ||
@@ -1029,6 +1045,7 @@ export async function runPreparedReply(
model,
level: resolvedThinkLevel,
catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
});
}
if (!thinkingLevelSupported) {
@@ -1038,7 +1055,7 @@ export async function runPreparedReply(
if (explicitThink) {
typing.cleanup();
return {
- text: `Thinking level "${resolvedThinkLevel}" is not supported for ${provider}/${model}. Use one of: ${formatThinkingLevels(provider, model, ", ", thinkingCatalog)}.`,
+ text: `Thinking level "${resolvedThinkLevel}" is not supported for ${provider}/${model}. Use one of: ${formatThinkingLevels(provider, model, ", ", thinkingCatalog, thinkingRuntime)}.`,
};
}
const fallbackThinkLevel = resolveSupportedThinkingLevel({
@@ -1046,6 +1063,7 @@ export async function runPreparedReply(
model,
level: resolvedThinkLevel,
catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
});
if (fallbackThinkLevel !== resolvedThinkLevel) {
// Execution fallbacks are turn-local; directive/model persistence owns
diff --git a/src/auto-reply/reply/model-selection.test.ts b/src/auto-reply/reply/model-selection.test.ts
index e2a0618565c1..cbb211c38715 100644
--- a/src/auto-reply/reply/model-selection.test.ts
+++ b/src/auto-reply/reply/model-selection.test.ts
@@ -151,42 +151,45 @@ describe("createModelSelectionState catalog loading", () => {
expect(loadModelCatalogLocal).not.toHaveBeenCalled();
});
- it("prefers per-model params.thinking over global thinkingDefault", async () => {
- vi.mocked(loadModelCatalogLocal).mockClear();
- const cfg = {
- agents: {
- defaults: {
- thinkingDefault: "low",
- models: {
- "openai-codex/gpt-5.4": {
- params: { thinking: "high" },
+ it.each(["high", "ultra"] as const)(
+ "prefers per-model params.thinking=%s over global thinkingDefault",
+ async (thinking) => {
+ vi.mocked(loadModelCatalogLocal).mockClear();
+ const cfg = {
+ agents: {
+ defaults: {
+ thinkingDefault: "low",
+ models: {
+ "openai-codex/gpt-5.4": {
+ params: { thinking },
+ },
},
},
},
- },
- models: {
- providers: {
- "openai-codex": {
- baseUrl: "https://api.openai.com/v1",
- models: [makeConfiguredModel()],
+ models: {
+ providers: {
+ "openai-codex": {
+ baseUrl: "https://api.openai.com/v1",
+ models: [makeConfiguredModel()],
+ },
},
},
- },
- } as OpenClawConfig;
+ } as OpenClawConfig;
- const state = await createModelSelectionState({
- cfg,
- agentCfg: cfg.agents?.defaults,
- defaultProvider: "openai-codex",
- defaultModel: "gpt-5.4",
- provider: "openai-codex",
- model: "gpt-5.4",
- hasModelDirective: false,
- });
+ const state = await createModelSelectionState({
+ cfg,
+ agentCfg: cfg.agents?.defaults,
+ defaultProvider: "openai-codex",
+ defaultModel: "gpt-5.4",
+ provider: "openai-codex",
+ model: "gpt-5.4",
+ hasModelDirective: false,
+ });
- await expect(state.resolveDefaultThinkingLevel()).resolves.toBe("high");
- expect(loadModelCatalogLocal).not.toHaveBeenCalled();
- });
+ await expect(state.resolveDefaultThinkingLevel()).resolves.toBe(thinking);
+ expect(loadModelCatalogLocal).not.toHaveBeenCalled();
+ },
+ );
it("keeps per-model disabled params.thinking ahead of global thinkingDefault", async () => {
vi.mocked(loadModelCatalogLocal).mockClear();
diff --git a/src/auto-reply/reply/model-selection.ts b/src/auto-reply/reply/model-selection.ts
index 766874b2d108..eb10056cbfb0 100644
--- a/src/auto-reply/reply/model-selection.ts
+++ b/src/auto-reply/reply/model-selection.ts
@@ -40,7 +40,7 @@ import type { SessionEntry } from "../../config/sessions/types.js";
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { applyModelOverrideToSessionEntry } from "../../sessions/model-overrides.js";
import { createLazyImportLoader } from "../../shared/lazy-promise.js";
-import type { ThinkLevel } from "./directives.js";
+import { normalizeThinkLevel, type ThinkLevel } from "../thinking.shared.js";
export {
resolveModelDirectiveSelection,
type ModelDirectiveSelection,
@@ -52,6 +52,12 @@ import {
type ModelCatalog = ModelCatalogEntry[];
+type ThinkingDefaultSelection = {
+ provider: string;
+ model: string;
+ agentRuntime?: string | null;
+};
+
type ModelSelectionState = {
provider: string;
model: string;
@@ -61,7 +67,7 @@ type ModelSelectionState = {
resetModelOverrideRef?: string;
resetModelOverrideReason?: "disallowed" | "stale";
resolveThinkingCatalog: () => Promise;
- resolveDefaultThinkingLevel: () => Promise;
+ resolveDefaultThinkingLevel: (selection?: ThinkingDefaultSelection) => Promise;
hasConfiguredThinkingDefault?: boolean;
/** Default reasoning level from model capability: "on" if model has reasoning, else "off". */
resolveDefaultReasoningLevel: () => Promise<"on" | "off">;
@@ -74,16 +80,7 @@ function resolveConfiguredModelThinkingDefault(raw: unknown): ThinkLevel | undef
if (raw === false || raw === "disabled" || raw === "none") {
return "off";
}
- return raw === "off" ||
- raw === "minimal" ||
- raw === "low" ||
- raw === "medium" ||
- raw === "high" ||
- raw === "xhigh" ||
- raw === "adaptive" ||
- raw === "max"
- ? raw
- : undefined;
+ return typeof raw === "string" ? normalizeThinkLevel(raw) : undefined;
}
/** Creates minimal model-selection state for fast test mode. */
@@ -547,19 +544,23 @@ export async function createModelSelectionState(params: {
return thinkingCatalog;
};
- let defaultThinkingLevel: ThinkLevel | undefined;
- const resolveDefaultThinkingLevel = async () => {
- if (defaultThinkingLevel) {
- return defaultThinkingLevel;
+ const defaultThinkingLevels = new Map();
+ const resolveDefaultThinkingLevel = async (selection?: ThinkingDefaultSelection) => {
+ const selectedProvider = selection?.provider ?? provider;
+ const selectedModel = selection?.model ?? model;
+ const cacheKey = `${modelKey(selectedProvider, selectedModel)}\0${selection?.agentRuntime ?? ""}`;
+ const cached = defaultThinkingLevels.get(cacheKey);
+ if (cached) {
+ return cached;
}
const agentThinkingDefault = agentEntry?.thinkingDefault as ThinkLevel | undefined;
if (agentThinkingDefault) {
- defaultThinkingLevel = agentThinkingDefault;
- return defaultThinkingLevel;
+ defaultThinkingLevels.set(cacheKey, agentThinkingDefault);
+ return agentThinkingDefault;
}
const configuredModels = cfg.agents?.defaults?.models;
- const canonicalKey = modelKey(provider, model);
- const legacyKey = legacyModelKey(provider, model);
+ const canonicalKey = modelKey(selectedProvider, selectedModel);
+ const legacyKey = legacyModelKey(selectedProvider, selectedModel);
const configuredModelThinkingDefault =
configuredModels?.[canonicalKey]?.params?.thinking ??
(legacyKey ? configuredModels?.[legacyKey]?.params?.thinking : undefined);
@@ -567,22 +568,24 @@ export async function createModelSelectionState(params: {
configuredModelThinkingDefault,
);
if (resolvedConfiguredModelThinkingDefault) {
- defaultThinkingLevel = resolvedConfiguredModelThinkingDefault;
- return defaultThinkingLevel;
+ defaultThinkingLevels.set(cacheKey, resolvedConfiguredModelThinkingDefault);
+ return resolvedConfiguredModelThinkingDefault;
}
const configuredThinkingDefault = agentCfg?.thinkingDefault as ThinkLevel | undefined;
if (configuredThinkingDefault) {
- defaultThinkingLevel = configuredThinkingDefault;
- return defaultThinkingLevel;
+ defaultThinkingLevels.set(cacheKey, configuredThinkingDefault);
+ return configuredThinkingDefault;
}
const catalogForThinking = await resolveThinkingCatalog();
const resolved = resolveThinkingDefault({
cfg,
- provider,
- model,
+ provider: selectedProvider,
+ model: selectedModel,
catalog: catalogForThinking,
+ agentRuntime: selection?.agentRuntime,
});
- defaultThinkingLevel = resolved ?? "off";
+ const defaultThinkingLevel = resolved ?? "off";
+ defaultThinkingLevels.set(cacheKey, defaultThinkingLevel);
return defaultThinkingLevel;
};
diff --git a/src/auto-reply/reply/queue/state.test.ts b/src/auto-reply/reply/queue/state.test.ts
index e9ecf8965997..dc4969fc7129 100644
--- a/src/auto-reply/reply/queue/state.test.ts
+++ b/src/auto-reply/reply/queue/state.test.ts
@@ -120,6 +120,69 @@ describe("refreshQueuedFollowupSession", () => {
modelOverrideSource: "user",
});
});
+
+ it("clamps queued Sol Ultra work to Codex Luna Max", () => {
+ const queue = getFollowupQueue(QUEUE_KEY, { mode: "followup" });
+ queue.items.push({
+ prompt: "queued message",
+ enqueuedAt: Date.now(),
+ run: {
+ ...makeRun(),
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ thinkLevel: "ultra",
+ },
+ });
+
+ refreshQueuedFollowupSession({
+ key: QUEUE_KEY,
+ nextProvider: "openai",
+ nextModel: "gpt-5.6-luna",
+ nextThinking: { level: "ultra", agentRuntime: "codex" },
+ });
+
+ expect(queue.items[0]?.run).toMatchObject({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ thinkLevel: "max",
+ });
+ });
+
+ it("uses the highest supported non-max level when retargeting queued work", () => {
+ const queue = getFollowupQueue(QUEUE_KEY, { mode: "followup" });
+ queue.items.push({
+ prompt: "queued message",
+ enqueuedAt: Date.now(),
+ run: { ...makeRun(), thinkLevel: "ultra" },
+ });
+
+ refreshQueuedFollowupSession({
+ key: QUEUE_KEY,
+ nextProvider: "custom",
+ nextModel: "reasoner",
+ nextThinking: { level: "ultra", agentRuntime: "openclaw" },
+ });
+
+ expect(queue.items[0]?.run.thinkLevel).toBe("high");
+ });
+
+ it("recomputes the retargeted model default when the session has no thinking override", () => {
+ const queue = getFollowupQueue(QUEUE_KEY, { mode: "followup" });
+ queue.items.push({
+ prompt: "queued message",
+ enqueuedAt: Date.now(),
+ run: { ...makeRun(), thinkLevel: "ultra" },
+ });
+
+ refreshQueuedFollowupSession({
+ key: QUEUE_KEY,
+ nextProvider: "openai",
+ nextModel: "gpt-5.6-sol",
+ nextThinking: { agentRuntime: "codex" },
+ });
+
+ expect(queue.items[0]?.run.thinkLevel).toBe("low");
+ });
});
describe("getFollowupQueue", () => {
diff --git a/src/auto-reply/reply/queue/state.ts b/src/auto-reply/reply/queue/state.ts
index 5e4f1f264d91..c1d513c26aad 100644
--- a/src/auto-reply/reply/queue/state.ts
+++ b/src/auto-reply/reply/queue/state.ts
@@ -2,6 +2,12 @@
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { resolveGlobalMap } from "../../../shared/global-singleton.js";
import { applyQueueRuntimeSettings } from "../../../utils/queue-helpers.js";
+import {
+ normalizeThinkLevel,
+ resolveSupportedThinkingLevel,
+ resolveThinkingDefaultForModel,
+ type ThinkingCatalogEntry,
+} from "../../thinking.js";
import {
completeFollowupRunLifecycle,
type FollowupRun,
@@ -183,6 +189,11 @@ export function refreshQueuedFollowupSession(params: {
nextModelOverrideSource?: "auto" | "user";
nextAuthProfileId?: string;
nextAuthProfileIdSource?: "auto" | "user";
+ nextThinking?: {
+ level?: string;
+ catalog?: ThinkingCatalogEntry[];
+ agentRuntime?: string | null;
+ };
}): void {
const cleaned = params.key.trim();
if (!cleaned) {
@@ -203,7 +214,8 @@ export function refreshQueuedFollowupSession(params: {
const shouldRewriteSelection =
shouldRewriteModelSelection ||
Object.hasOwn(params, "nextAuthProfileId") ||
- Object.hasOwn(params, "nextAuthProfileIdSource");
+ Object.hasOwn(params, "nextAuthProfileIdSource") ||
+ params.nextThinking !== undefined;
if (!shouldRewriteSession && !shouldRewriteSelection) {
return;
}
@@ -239,6 +251,23 @@ export function refreshQueuedFollowupSession(params: {
if (Object.hasOwn(params, "nextAuthProfileIdSource")) {
run.authProfileIdSource = run.authProfileId ? params.nextAuthProfileIdSource : undefined;
}
+ if (params.nextThinking) {
+ const explicitLevel = normalizeThinkLevel(params.nextThinking.level);
+ run.thinkLevel = explicitLevel
+ ? resolveSupportedThinkingLevel({
+ provider: run.provider,
+ model: run.model,
+ level: explicitLevel,
+ catalog: params.nextThinking.catalog,
+ agentRuntime: params.nextThinking.agentRuntime,
+ })
+ : resolveThinkingDefaultForModel({
+ provider: run.provider,
+ model: run.model,
+ catalog: params.nextThinking.catalog,
+ agentRuntime: params.nextThinking.agentRuntime,
+ });
+ }
}
};
diff --git a/src/auto-reply/thinking.shared.ts b/src/auto-reply/thinking.shared.ts
index 2a0f35f9004c..74522be9d335 100644
--- a/src/auto-reply/thinking.shared.ts
+++ b/src/auto-reply/thinking.shared.ts
@@ -18,7 +18,8 @@ export type ThinkLevel =
| "high"
| "xhigh"
| "adaptive"
- | "max";
+ | "max"
+ | "ultra";
export type VerboseLevel = "off" | "on" | "full";
export type TraceLevel = "off" | "on" | "raw";
export type NoticeLevel = "off" | "on" | "full";
@@ -38,6 +39,19 @@ export type ThinkingCatalogEntry = {
} | null;
};
+/** Complete canonical level set accepted by user-facing thinking controls. */
+export const ALL_THINKING_LEVELS: readonly ThinkLevel[] = [
+ "off",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "adaptive",
+ "max",
+ "ultra",
+];
+export const THINKING_LEVELS_HELP = ALL_THINKING_LEVELS.join("|");
export const BASE_THINKING_LEVELS: ThinkLevel[] = ["off", "minimal", "low", "medium", "high"];
export const THINKING_LEVEL_RANKS: Record = {
off: 0,
@@ -48,6 +62,7 @@ export const THINKING_LEVEL_RANKS: Record = {
adaptive: 30,
xhigh: 60,
max: 70,
+ ultra: 80,
};
/** Normalizes user-provided thinking level strings to the canonical enum. */
@@ -63,6 +78,9 @@ export function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined
if (collapsed === "max") {
return "max";
}
+ if (collapsed === "ultra") {
+ return "ultra";
+ }
if (collapsed === "xhigh" || collapsed === "extrahigh") {
return "xhigh";
}
@@ -81,7 +99,7 @@ export function normalizeThinkLevel(raw?: string | null): ThinkLevel | undefined
if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) {
return "medium";
}
- if (["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest"].includes(key)) {
+ if (["high", "ultrathink", "think-hard", "thinkhardest", "highest"].includes(key)) {
return "high";
}
if (["think"].includes(key)) {
diff --git a/src/auto-reply/thinking.test.ts b/src/auto-reply/thinking.test.ts
index 0cda79843cb1..8a9b3a8732ca 100644
--- a/src/auto-reply/thinking.test.ts
+++ b/src/auto-reply/thinking.test.ts
@@ -78,6 +78,12 @@ describe("normalizeThinkLevel", () => {
expect(normalizeThinkLevel("max")).toBe("max");
expect(normalizeThinkLevel("MAX")).toBe("max");
});
+
+ it("keeps explicit Ultra distinct from the legacy ultrathink alias", () => {
+ expect(normalizeThinkLevel("ultra")).toBe("ultra");
+ expect(normalizeThinkLevel("ULTRA")).toBe("ultra");
+ expect(normalizeThinkLevel("ultrathink")).toBe("high");
+ });
});
describe("listThinkingLevels", () => {
@@ -120,6 +126,23 @@ describe("listThinkingLevels", () => {
expect(listThinkingLevels("openai", "gpt-5.4")).not.toContain("max");
});
+ it("passes the effective agent runtime into provider thinking profiles", () => {
+ providerRuntimeMocks.resolveProviderThinkingProfile.mockImplementation(({ context }) => ({
+ levels: [
+ { id: "off" },
+ { id: "max" },
+ ...(context.agentRuntime === "openclaw" ? [{ id: "ultra" as const }] : []),
+ ],
+ }));
+
+ expect(listThinkingLevels("openai", "gpt-5.6-luna", undefined, "openclaw")).toContain("ultra");
+ expect(listThinkingLevels("openai", "gpt-5.6-luna", undefined, "codex")).not.toContain("ultra");
+ expect(providerRuntimeMocks.resolveProviderThinkingProfile).toHaveBeenLastCalledWith({
+ provider: "openai",
+ context: expect.objectContaining({ agentRuntime: "codex" }),
+ });
+ });
+
it("does not include adaptive without provider support", () => {
expect(listThinkingLevels(undefined, "gpt-4.1-mini")).not.toContain("adaptive");
expect(listThinkingLevels("openai", "gpt-5.4")).not.toContain("adaptive");
@@ -874,7 +897,9 @@ describe("resolveEffectiveResponseUsage", () => {
// Explicit "off" is stored and wins — non-off config default cannot re-enable it.
expect(resolveEffectiveResponseUsage("off", "tokens")).toBe("off");
expect(resolveEffectiveResponseUsage("off", "full")).toBe("off");
- expect(resolveEffectiveResponseUsage("off", { default: "full", discord: "full" }, "discord")).toBe("off");
+ expect(
+ resolveEffectiveResponseUsage("off", { default: "full", discord: "full" }, "discord"),
+ ).toBe("off");
});
it("session explicit on value overrides config default", () => {
@@ -888,6 +913,6 @@ describe("resolveEffectiveResponseUsage", () => {
// - "off" = explicit off → stays off
const cfg = "tokens" as const;
expect(resolveEffectiveResponseUsage(undefined, cfg)).toBe("tokens"); // inherits
- expect(resolveEffectiveResponseUsage("off", cfg)).toBe("off"); // explicit off persists
+ expect(resolveEffectiveResponseUsage("off", cfg)).toBe("off"); // explicit off persists
});
});
diff --git a/src/auto-reply/thinking.ts b/src/auto-reply/thinking.ts
index f331ffa9c26c..ac08ad36a6f6 100644
--- a/src/auto-reply/thinking.ts
+++ b/src/auto-reply/thinking.ts
@@ -182,6 +182,7 @@ export function resolveThinkingProfile(params: {
provider?: string | null;
model?: string | null;
catalog?: ThinkingCatalogEntry[];
+ agentRuntime?: string | null;
}): ResolvedThinkingProfile {
const context = resolveThinkingPolicyContext(params);
if (!context.normalizedProvider) {
@@ -190,6 +191,7 @@ export function resolveThinkingProfile(params: {
const providerContext = {
provider: context.normalizedProvider,
modelId: context.modelId,
+ agentRuntime: params.agentRuntime,
reasoning: context.reasoning,
...(context.params ? { params: context.params } : {}),
compat: context.compat,
@@ -262,8 +264,9 @@ function supportsThinkingLevel(
model: string | null | undefined,
level: ThinkLevel,
catalog?: ThinkingCatalogEntry[],
+ agentRuntime?: string | null,
): boolean {
- return resolveThinkingProfile({ provider, model, catalog }).levels.some(
+ return resolveThinkingProfile({ provider, model, catalog, agentRuntime }).levels.some(
(entry) => entry.id === level,
);
}
@@ -273,8 +276,9 @@ export function listThinkingLevels(
provider?: string | null,
model?: string | null,
catalog?: ThinkingCatalogEntry[],
+ agentRuntime?: string | null,
): ThinkLevel[] {
- const profile = resolveThinkingProfile({ provider, model, catalog });
+ const profile = resolveThinkingProfile({ provider, model, catalog, agentRuntime });
return profile.levels.map((level) => level.id);
}
@@ -283,8 +287,9 @@ export function listThinkingLevelOptions(
provider?: string | null,
model?: string | null,
catalog?: ThinkingCatalogEntry[],
+ agentRuntime?: string | null,
): ThinkingLevelOption[] {
- const profile = resolveThinkingProfile({ provider, model, catalog });
+ const profile = resolveThinkingProfile({ provider, model, catalog, agentRuntime });
return profile.levels.map(({ id, label }) => ({ id, label }));
}
@@ -293,8 +298,11 @@ export function listThinkingLevelLabels(
provider?: string | null,
model?: string | null,
catalog?: ThinkingCatalogEntry[],
+ agentRuntime?: string | null,
): string[] {
- return listThinkingLevelOptions(provider, model, catalog).map((level) => level.label);
+ return listThinkingLevelOptions(provider, model, catalog, agentRuntime).map(
+ (level) => level.label,
+ );
}
/** Format supported thinking level labels for command/status output. */
@@ -303,8 +311,9 @@ export function formatThinkingLevels(
model?: string | null,
separator = ", ",
catalog?: ThinkingCatalogEntry[],
+ agentRuntime?: string | null,
): string {
- const profile = resolveThinkingProfile({ provider, model, catalog });
+ const profile = resolveThinkingProfile({ provider, model, catalog, agentRuntime });
return profile.levels.map(({ label }) => label).join(separator);
}
@@ -313,11 +322,13 @@ export function resolveThinkingDefaultForModel(params: {
provider: string;
model: string;
catalog?: ThinkingCatalogEntry[];
+ agentRuntime?: string | null;
}): ThinkLevel {
const profile = resolveThinkingProfile({
provider: params.provider,
model: params.model,
catalog: params.catalog,
+ agentRuntime: params.agentRuntime,
});
if (profile.defaultLevel) {
return profile.defaultLevel;
@@ -335,8 +346,15 @@ export function isThinkingLevelSupported(params: {
model?: string | null;
level: ThinkLevel;
catalog?: ThinkingCatalogEntry[];
+ agentRuntime?: string | null;
}): boolean {
- return supportsThinkingLevel(params.provider, params.model, params.level, params.catalog);
+ return supportsThinkingLevel(
+ params.provider,
+ params.model,
+ params.level,
+ params.catalog,
+ params.agentRuntime,
+ );
}
function resolveSupportedThinkingLevelFromProfile(
@@ -361,11 +379,13 @@ export function resolveSupportedThinkingLevel(params: {
model?: string | null;
level: ThinkLevel;
catalog?: ThinkingCatalogEntry[];
+ agentRuntime?: string | null;
}): ThinkLevel {
const profile = resolveThinkingProfile({
provider: params.provider,
model: params.model,
catalog: params.catalog,
+ agentRuntime: params.agentRuntime,
});
return resolveSupportedThinkingLevelFromProfile(profile, params.level);
}
diff --git a/src/cli/cron-cli/register.cron-add.ts b/src/cli/cron-cli/register.cron-add.ts
index 4b41c6b32fc3..6c37e917b9fd 100644
--- a/src/cli/cron-cli/register.cron-add.ts
+++ b/src/cli/cron-cli/register.cron-add.ts
@@ -5,6 +5,7 @@ import {
} from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
import { theme } from "../../../packages/terminal-core/src/theme.js";
+import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js";
import type { CronJob } from "../../cron/types.js";
import { sanitizeAgentId } from "../../routing/session-key.js";
import { defaultRuntime } from "../../runtime.js";
@@ -127,10 +128,7 @@ export function registerCronAddCommand(cron: Command) {
(value: string, previous: string[] | undefined) => [...(previous ?? []), value],
)
.option("--command-input ", "stdin for command payloads")
- .option(
- "--thinking ",
- "Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
- )
+ .option("--thinking ", `Thinking level for agent jobs (${THINKING_LEVELS_HELP})`)
.option("--model ", "Model override for agent jobs (provider/model or alias)")
.option("--fallbacks ]", "Fallback model list for agent jobs")
.option("--timeout-seconds ", "Timeout seconds for agent or command jobs")
diff --git a/src/cli/cron-cli/register.cron-edit.ts b/src/cli/cron-cli/register.cron-edit.ts
index 46196437c09d..b50bc21b4125 100644
--- a/src/cli/cron-cli/register.cron-edit.ts
+++ b/src/cli/cron-cli/register.cron-edit.ts
@@ -4,6 +4,7 @@ import {
normalizeOptionalString,
} from "@openclaw/normalization-core/string-coerce";
import type { Command } from "commander";
+import { THINKING_LEVELS_HELP } from "../../auto-reply/thinking.shared.js";
import type { CronJob } from "../../cron/types.js";
import { danger } from "../../globals.js";
import { parseStrictPositiveInteger } from "../../infra/parse-finite-number.js";
@@ -133,10 +134,7 @@ export function registerCronEditCommand(cron: Command) {
(value: string, previous: string[] | undefined) => [...(previous ?? []), value],
)
.option("--command-input ", "Set command payload stdin")
- .option(
- "--thinking ",
- "Thinking level for agent jobs (off|minimal|low|medium|high|xhigh)",
- )
+ .option("--thinking ", `Thinking level for agent jobs (${THINKING_LEVELS_HELP})`)
.option(
"--clear-thinking",
"Remove the per-job thinking override (restore normal cron thinking precedence)",
diff --git a/src/cli/cron-cli/trigger-options.test.ts b/src/cli/cron-cli/trigger-options.test.ts
index 676c151b2264..5fcafddde09e 100644
--- a/src/cli/cron-cli/trigger-options.test.ts
+++ b/src/cli/cron-cli/trigger-options.test.ts
@@ -31,6 +31,19 @@ describe("cron trigger CLI options", () => {
await fs.rm(fixtureRoot, { recursive: true, force: true });
});
+ it("advertises every canonical thinking level on add and edit", () => {
+ const program = new Command().exitOverride();
+ registerCronAddCommand(program);
+ registerCronEditCommand(program);
+
+ for (const commandName of ["add", "edit"]) {
+ const help = program.commands
+ .find((command) => command.name() === commandName)
+ ?.helpInformation();
+ expect(help).toContain("off|minimal|low|medium|high|xhigh|adaptive|max|ultra");
+ }
+ });
+
it("reads --trigger-script client-side and sends trigger metadata on add", async () => {
const scriptPath = path.join(fixtureRoot, "watch.js");
await fs.writeFile(scriptPath, " json({ fire: true }) \n", "utf8");
diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts
index 999c2452d6a5..4fe2cbd3516a 100644
--- a/src/config/types.agent-defaults.ts
+++ b/src/config/types.agent-defaults.ts
@@ -352,7 +352,16 @@ export type AgentDefaultsConfig = {
/** Vector memory search configuration (per-agent overrides supported). */
memorySearch?: MemorySearchConfig;
/** Default thinking level when no /think directive is present. */
- thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max";
+ thinkingDefault?:
+ | "off"
+ | "minimal"
+ | "low"
+ | "medium"
+ | "high"
+ | "xhigh"
+ | "adaptive"
+ | "max"
+ | "ultra";
/** Default verbose level when no /verbose directive is present. */
verboseDefault?: "off" | "on" | "full";
/**
diff --git a/src/config/types.agents.ts b/src/config/types.agents.ts
index c7944322deac..2332eead8052 100644
--- a/src/config/types.agents.ts
+++ b/src/config/types.agents.ts
@@ -99,7 +99,7 @@ export type AgentConfig = {
/** @deprecated Legacy per-agent compaction config is kept for raw doctor migration/repair. */
compaction?: AgentDefaultsConfig["compaction"];
/** Optional per-agent default thinking level (overrides agents.defaults.thinkingDefault). */
- thinkingDefault?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "adaptive" | "max";
+ thinkingDefault?: AgentDefaultsConfig["thinkingDefault"];
/** Optional per-agent default verbosity level. */
verboseDefault?: "off" | "on" | "full";
/** Optional per-agent tool progress detail mode. */
diff --git a/src/config/types.gateway.ts b/src/config/types.gateway.ts
index 30a81585a2bb..1e5cb2c024b5 100644
--- a/src/config/types.gateway.ts
+++ b/src/config/types.gateway.ts
@@ -107,7 +107,8 @@ export type TalkConfig = {
| "high"
| "xhigh"
| "adaptive"
- | "max";
+ | "max"
+ | "ultra";
/** Optional fast mode override for the agent run behind Talk realtime consults. */
consultFastMode?: boolean;
/** BCP 47 locale id used for Talk speech recognition on device nodes. */
diff --git a/src/config/zod-schema.agent-defaults.ts b/src/config/zod-schema.agent-defaults.ts
index 779390aa786b..3038db89ef93 100644
--- a/src/config/zod-schema.agent-defaults.ts
+++ b/src/config/zod-schema.agent-defaults.ts
@@ -216,6 +216,7 @@ export const AgentDefaultsSchema = z
z.literal("xhigh"),
z.literal("adaptive"),
z.literal("max"),
+ z.literal("ultra"),
])
.optional(),
verboseDefault: z.union([z.literal("off"), z.literal("on"), z.literal("full")]).optional(),
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index 7e8663007e51..b3f68e570231 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -1042,7 +1042,7 @@ export const AgentEntrySchema = z
utilityModel: z.string().optional(),
models: z.record(z.string(), AgentModelRuntimeEntrySchema).optional(),
thinkingDefault: z
- .enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"])
+ .enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max", "ultra"])
.optional(),
verboseDefault: z.enum(["off", "on", "full"]).optional(),
toolProgressDetail: z.enum(["explain", "raw"]).optional(),
diff --git a/src/config/zod-schema.ts b/src/config/zod-schema.ts
index fe3a9224135b..e758763500d6 100644
--- a/src/config/zod-schema.ts
+++ b/src/config/zod-schema.ts
@@ -355,7 +355,7 @@ const TalkSchema = z
providers: z.record(z.string(), TalkProviderEntrySchema).optional(),
realtime: TalkRealtimeSchema.optional(),
consultThinkingLevel: z
- .enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max"])
+ .enum(["off", "minimal", "low", "medium", "high", "xhigh", "adaptive", "max", "ultra"])
.optional(),
consultFastMode: z.boolean().optional(),
speechLocale: z.string().optional(),
diff --git a/src/cron/isolated-agent/run-execution.runtime.ts b/src/cron/isolated-agent/run-execution.runtime.ts
index 4cc4c296add0..f67a7bffbdea 100644
--- a/src/cron/isolated-agent/run-execution.runtime.ts
+++ b/src/cron/isolated-agent/run-execution.runtime.ts
@@ -8,6 +8,7 @@ export { resolveCronAgentLane } from "../../agents/lanes.js";
export { ensureSelectedAgentHarnessPlugin } from "../../agents/harness/runtime-plugin.js";
export { LiveSessionModelSwitchError } from "../../agents/live-model-switch-error.js";
export { runWithModelFallback } from "../../agents/model-fallback.js";
+export { resolveCandidateThinkingLevel } from "../../agents/thinking-runtime.js";
export {
classifyEmbeddedAgentRunResultForModelFallback,
mergeEmbeddedAgentRunResultForModelFallbackExhaustion,
diff --git a/src/cron/isolated-agent/run-executor.ts b/src/cron/isolated-agent/run-executor.ts
index 69b655d0a943..6bfc33dc9ffe 100644
--- a/src/cron/isolated-agent/run-executor.ts
+++ b/src/cron/isolated-agent/run-executor.ts
@@ -5,8 +5,10 @@ import type { BootstrapContextMode } from "../../agents/bootstrap-files.js";
import { resolveCliRuntimeToolsAllow } from "../../agents/cli-runner/tool-policy.js";
import type { FastModeAutoProgressState } from "../../agents/fast-mode.js";
import { runAgentHarnessBeforeMessageWriteHook } from "../../agents/harness/hook-helpers.js";
+import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { resolveCliRuntimeExecutionProvider } from "../../agents/model-runtime-aliases.js";
import { wrapUntrustedPromptDataBlock } from "../../agents/sanitize-for-prompt.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
import type { ThinkLevel, VerboseLevel } from "../../auto-reply/thinking.js";
import type { CliSessionBinding } from "../../config/sessions.js";
import type { AgentDefaultsConfig } from "../../config/types.agent-defaults.js";
@@ -39,6 +41,7 @@ import {
normalizeVerboseLevel,
registerAgentRunContext,
resolveBootstrapWarningSignaturesSeen,
+ resolveCandidateThinkingLevel,
resolveCronAgentLane,
resolveSessionTranscriptPath,
runCliAgent,
@@ -201,6 +204,7 @@ export function createCronPromptExecutor(params: {
lane?: string;
resolvedVerboseLevel: VerboseLevel;
thinkLevel: ThinkLevel | undefined;
+ thinkingCatalog?: ModelCatalogEntry[];
timeoutMs: number;
/** Set when the cron payload's `timeoutSeconds` was explicitly configured. */
runTimeoutOverrideMs?: number;
@@ -321,6 +325,12 @@ export function createCronPromptExecutor(params: {
agentId: params.agentId,
sessionKey: params.runSessionKey,
abortSignal: params.abortSignal,
+ resolveAgentHarnessRuntimeOverride: (provider) =>
+ resolveSessionRuntimeOverrideForProvider({
+ provider,
+ entry: params.cronSession.sessionEntry,
+ cfg: params.cfgWithAgentDefaults,
+ }),
prepareAgentHarnessRuntime: async ({ provider, model, agentHarnessRuntimeOverride }) => {
await ensureSelectedAgentHarnessPlugin({
config: params.cfgWithAgentDefaults,
@@ -353,13 +363,34 @@ export function createCronPromptExecutor(params: {
params.cronSession.sessionEntry.modelProvider = providerOverride;
params.cronSession.sessionEntry.model = modelOverride;
await params.persistRunContinuationSession?.();
+ const sessionRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
+ provider: providerOverride,
+ entry: params.cronSession.sessionEntry,
+ cfg: params.cfgWithAgentDefaults,
+ });
+ const candidateThinkLevel = resolveCandidateThinkingLevel({
+ cfg: params.cfgWithAgentDefaults,
+ provider: providerOverride,
+ modelId: modelOverride,
+ level: params.thinkLevel,
+ catalog: params.thinkingCatalog,
+ agentId: params.agentId,
+ sessionKey: params.runSessionKey,
+ sessionEntry: params.cronSession.sessionEntry,
+ });
const executionProvider =
- resolveCliRuntimeExecutionProvider({
- provider: providerOverride,
- cfg: params.cfgWithAgentDefaults,
- agentId: params.agentId,
- modelId: modelOverride,
- }) ?? providerOverride;
+ (sessionRuntimeOverride &&
+ isCliProvider(sessionRuntimeOverride, params.cfgWithAgentDefaults)
+ ? sessionRuntimeOverride
+ : undefined) ??
+ (sessionRuntimeOverride
+ ? providerOverride
+ : (resolveCliRuntimeExecutionProvider({
+ provider: providerOverride,
+ cfg: params.cfgWithAgentDefaults,
+ agentId: params.agentId,
+ modelId: modelOverride,
+ }) ?? providerOverride));
const cliExecution = isCliProvider(executionProvider, params.cfgWithAgentDefaults);
await params.setRunContinuationCliExecutionProvider?.(
cliExecution ? executionProvider : undefined,
@@ -391,7 +422,7 @@ export function createCronPromptExecutor(params: {
transcriptPrompt: deliveryTargetRuntimeContext ? promptText : undefined,
provider: executionProvider,
model: modelOverride,
- thinkLevel: params.thinkLevel,
+ thinkLevel: candidateThinkLevel,
timeoutMs: params.timeoutMs,
runId: params.cronSession.sessionEntry.sessionId,
lane: resolveCronAgentLane(params.lane),
@@ -467,6 +498,7 @@ export function createCronPromptExecutor(params: {
lane: resolveCronAgentLane(params.lane),
provider: providerOverride,
model: modelOverride,
+ agentHarnessRuntimeOverride: sessionRuntimeOverride,
modelFallbacksOverride: cronFallbacksOverride,
authProfileId: params.liveSelection.authProfileId,
authProfileIdSource: params.liveSelection.authProfileId
@@ -475,7 +507,9 @@ export function createCronPromptExecutor(params: {
// Scheduled run: keep bursty cron overloaded/rate_limit local, while
// still sharing real credential/account failures across auth profiles.
authProfileFailurePolicy: "local_transient",
- thinkLevel: params.thinkLevel,
+ // Fallback selection is turn-local. Revalidate the stored or
+ // requested level without rewriting the durable preference.
+ thinkLevel: candidateThinkLevel,
...(() => {
const fastModeState = resolveFastModeState({
cfg: params.cfgWithAgentDefaults,
@@ -594,6 +628,7 @@ export async function executeCronRun(params: {
) => void;
onLaneWait?: (info?: { waiting?: boolean }) => void;
thinkLevel: ThinkLevel | undefined;
+ thinkingCatalog?: ModelCatalogEntry[];
timeoutMs: number;
/** Set when the cron payload's `timeoutSeconds` was explicitly configured. */
runTimeoutOverrideMs?: number;
@@ -629,6 +664,7 @@ export async function executeCronRun(params: {
lane: params.lane,
resolvedVerboseLevel,
thinkLevel: params.thinkLevel,
+ thinkingCatalog: params.thinkingCatalog,
timeoutMs: params.timeoutMs,
runTimeoutOverrideMs: params.runTimeoutOverrideMs,
suppressExecNotifyOnExit: params.suppressExecNotifyOnExit,
@@ -678,6 +714,7 @@ export async function executeCronRun(params: {
}
params.liveSelection.provider = err.provider;
params.liveSelection.model = err.model;
+ params.liveSelection.agentRuntimeOverride = err.agentRuntimeOverride;
params.liveSelection.authProfileId = err.authProfileId;
params.liveSelection.authProfileIdSource = err.authProfileId
? err.authProfileIdSource
diff --git a/src/cron/isolated-agent/run-session-state.ts b/src/cron/isolated-agent/run-session-state.ts
index de00e4574374..9d7d21a50e31 100644
--- a/src/cron/isolated-agent/run-session-state.ts
+++ b/src/cron/isolated-agent/run-session-state.ts
@@ -314,6 +314,11 @@ export function syncCronSessionLiveSelection(params: {
}) {
params.entry.modelProvider = params.liveSelection.provider;
params.entry.model = params.liveSelection.model;
+ if (params.liveSelection.agentRuntimeOverride) {
+ params.entry.agentRuntimeOverride = params.liveSelection.agentRuntimeOverride;
+ } else {
+ delete params.entry.agentRuntimeOverride;
+ }
if (params.liveSelection.authProfileId) {
params.entry.authProfileOverride = params.liveSelection.authProfileId;
params.entry.authProfileOverrideSource = params.liveSelection.authProfileIdSource;
diff --git a/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts b/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts
index bda8a61c966d..c114f0620d50 100644
--- a/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts
+++ b/src/cron/isolated-agent/run.cron-model-override-forwarding.test.ts
@@ -16,6 +16,8 @@ import {
resolveConfiguredModelRefMock,
resolveCronSessionMock,
resolveSupportedThinkingLevelMock,
+ resolveEffectiveAgentRuntimeMock,
+ resolveThinkingDefaultMock,
resetRunCronIsolatedAgentTurnHarness,
restoreFastTestEnv,
runEmbeddedAgentMock,
@@ -442,6 +444,253 @@ describe("runCronIsolatedAgentTurn — cron model override forwarding (#58065)",
expect(embeddedCall.thinkLevel).toBe("medium");
});
+ it("uses a stored cron-session thinking preference before configured defaults", async () => {
+ resolveAllowedModelRefMock.mockReturnValue({
+ ref: { provider: "openai", model: "gpt-5.6-luna" },
+ });
+ resolveEffectiveAgentRuntimeMock.mockReturnValue("openclaw");
+ resolveCronSessionMock.mockReturnValue(
+ makeCronSession({
+ sessionEntry: makeCronSessionEntry({
+ modelOverride: "gpt-5.6-luna",
+ providerOverride: "openai",
+ modelOverrideSource: "user",
+ agentRuntimeOverride: "openclaw",
+ thinkingLevel: "ultra",
+ }),
+ isNewSession: true,
+ }),
+ );
+ runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => ({
+ result: await run(provider, model),
+ provider,
+ model,
+ attempts: [],
+ }));
+
+ await runCronIsolatedAgentTurn(
+ makeParams({
+ job: makeJob({
+ payload: {
+ kind: "agentTurn",
+ message: "summarize",
+ model: "openai/gpt-5.6-luna",
+ },
+ }),
+ }),
+ );
+
+ expect(resolveThinkingDefaultMock).not.toHaveBeenCalled();
+ expect(isThinkingLevelSupportedMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ level: "ultra",
+ agentRuntime: "openclaw",
+ }),
+ );
+ expect(firstMockArg(runEmbeddedAgentMock).thinkLevel).toBe("ultra");
+ });
+
+ it.each([
+ { model: "gpt-5.6-sol", requested: "ultra", supported: true, expected: "ultra" },
+ { model: "gpt-5.6-terra", requested: "ultra", supported: true, expected: "ultra" },
+ { model: "gpt-5.6-luna", requested: "ultra", supported: false, expected: "max" },
+ ])(
+ "applies Codex runtime thinking policy for $model",
+ async ({ model: modelId, requested, supported, expected }) => {
+ resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: modelId } });
+ resolveEffectiveAgentRuntimeMock.mockReturnValue("codex");
+ isThinkingLevelSupportedMock.mockImplementation(
+ ({ agentRuntime, level }: { agentRuntime?: string; level?: string }) =>
+ agentRuntime === "codex" && level === requested && supported,
+ );
+ resolveSupportedThinkingLevelMock.mockReturnValue(expected);
+ runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => ({
+ result: await run(provider, model),
+ provider,
+ model,
+ attempts: [],
+ }));
+
+ await runCronIsolatedAgentTurn(
+ makeParams({
+ job: makeJob({
+ payload: {
+ kind: "agentTurn",
+ message: "summarize",
+ model: `openai/${modelId}`,
+ thinking: requested,
+ },
+ }),
+ }),
+ );
+
+ expect(resolveEffectiveAgentRuntimeMock).toHaveBeenCalledWith(
+ expect.objectContaining({ provider: "openai", modelId }),
+ );
+ expect(isThinkingLevelSupportedMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: "openai",
+ model: modelId,
+ level: requested,
+ agentRuntime: "codex",
+ }),
+ );
+ if (!supported) {
+ expect(resolveSupportedThinkingLevelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: "openai",
+ model: modelId,
+ level: requested,
+ agentRuntime: "codex",
+ }),
+ );
+ }
+ expect(firstMockArg(runEmbeddedAgentMock).thinkLevel).toBe(expected);
+ },
+ );
+
+ it("revalidates thinking for each model fallback without persisting the remap", async () => {
+ resolveAllowedModelRefMock.mockReturnValue({
+ ref: { provider: "openai", model: "gpt-5.6-sol" },
+ });
+ resolveEffectiveAgentRuntimeMock.mockReturnValue("openclaw");
+ isThinkingLevelSupportedMock.mockImplementation(
+ ({ model }: { model?: string }) => model !== "gpt-5.5",
+ );
+ resolveSupportedThinkingLevelMock.mockImplementation(
+ ({ level, model }: { level?: string; model?: string }) =>
+ model === "gpt-5.5" ? "high" : level,
+ );
+ loadModelCatalogMock.mockResolvedValue([
+ { provider: "openai", id: "gpt-5.6-sol", reasoning: true },
+ { provider: "openai", id: "gpt-5.5", reasoning: true },
+ ]);
+ const cronSession = makeCronSession({
+ sessionEntry: makeCronSessionEntry({
+ thinkingLevel: "ultra",
+ agentRuntimeOverride: "openclaw",
+ }),
+ isNewSession: true,
+ });
+ resolveCronSessionMock.mockReturnValue(cronSession);
+ runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
+ await run(provider, model);
+ const result = await run("openai", "gpt-5.5");
+ return {
+ result,
+ provider: "openai",
+ model: "gpt-5.5",
+ attempts: [],
+ };
+ });
+
+ const result = await runCronIsolatedAgentTurn(
+ makeParams({
+ cfg: {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "openclaw" } },
+ "openai/gpt-5.5": { agentRuntime: { id: "openclaw" } },
+ },
+ },
+ },
+ },
+ job: makeJob({
+ payload: {
+ kind: "agentTurn",
+ message: "summarize",
+ model: "openai/gpt-5.6-sol",
+ },
+ }),
+ }),
+ );
+
+ expect(result.status).toBe("ok");
+ expect(runEmbeddedAgentMock.mock.calls.map((call) => call[0].thinkLevel)).toEqual([
+ "ultra",
+ "high",
+ ]);
+ expect(resolveSupportedThinkingLevelMock).toHaveBeenCalledWith(
+ expect.objectContaining({
+ provider: "openai",
+ model: "gpt-5.5",
+ level: "ultra",
+ agentRuntime: "openclaw",
+ }),
+ );
+ expect(cronSession.sessionEntry.thinkingLevel).toBe("ultra");
+ });
+
+ it("restores the requested thinking level when a later fallback supports it", async () => {
+ resolveAllowedModelRefMock.mockImplementation(({ raw }: { raw: string }) => {
+ const [provider, model] = raw.split("/");
+ return { ref: { provider, model } };
+ });
+ resolveEffectiveAgentRuntimeMock.mockReturnValue("codex");
+ isThinkingLevelSupportedMock.mockImplementation(
+ ({ model, level }: { model?: string; level?: string }) =>
+ model === "gpt-5.6-sol" || level !== "ultra",
+ );
+ resolveSupportedThinkingLevelMock.mockImplementation(
+ ({ model, level }: { model?: string; level?: string }) =>
+ model === "gpt-5.6-luna" && level === "ultra" ? "max" : level,
+ );
+ loadModelCatalogMock.mockResolvedValue([
+ { provider: "openai", id: "gpt-5.6-luna", reasoning: true },
+ { provider: "openai", id: "gpt-5.6-sol", reasoning: true },
+ ]);
+ const cronSession = makeCronSession({
+ sessionEntry: makeCronSessionEntry({
+ thinkingLevel: "ultra",
+ agentRuntimeOverride: "codex",
+ }),
+ isNewSession: true,
+ });
+ resolveCronSessionMock.mockReturnValue(cronSession);
+ runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => {
+ await run(provider, model);
+ const result = await run("openai", "gpt-5.6-sol");
+ return {
+ result,
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ attempts: [],
+ };
+ });
+
+ const result = await runCronIsolatedAgentTurn(
+ makeParams({
+ cfg: {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
+ "openai/gpt-5.6-sol": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ },
+ job: makeJob({
+ payload: {
+ kind: "agentTurn",
+ message: "summarize",
+ model: "openai/gpt-5.6-luna",
+ },
+ }),
+ }),
+ );
+
+ expect(result.status).toBe("ok");
+ expect(runEmbeddedAgentMock.mock.calls.map((call) => call[0].thinkLevel)).toEqual([
+ "max",
+ "ultra",
+ ]);
+ expect(cronSession.sessionEntry.thinkingLevel).toBe("ultra");
+ });
+
it("does not add agent primary model as fallback when cron payload model is set", async () => {
// No per-agent fallbacks configured — resolveAgentModelFallbacksOverride
// returns undefined in that case. Before the fix, this caused
diff --git a/src/cron/isolated-agent/run.live-session-model-switch.test.ts b/src/cron/isolated-agent/run.live-session-model-switch.test.ts
index ecf652d00b3c..606ff2d2477e 100644
--- a/src/cron/isolated-agent/run.live-session-model-switch.test.ts
+++ b/src/cron/isolated-agent/run.live-session-model-switch.test.ts
@@ -69,6 +69,7 @@ function makeSuccessfulRunResult(modelUsed = "claude-sonnet-4-6") {
function requireEmbeddedAgentCall(index: number): {
provider?: string;
model?: string;
+ agentHarnessRuntimeOverride?: string;
authProfileId?: string;
authProfileIdSource?: string;
suppressNextUserMessagePersistence?: boolean;
@@ -80,6 +81,7 @@ function requireEmbeddedAgentCall(index: number): {
| {
provider?: string;
model?: string;
+ agentHarnessRuntimeOverride?: string;
authProfileId?: string;
authProfileIdSource?: string;
suppressNextUserMessagePersistence?: boolean;
@@ -250,6 +252,63 @@ describe("runCronIsolatedAgentTurn — LiveSessionModelSwitchError retry (#57206
expect(cronSession.sessionEntry.authProfileOverrideSource).toBe("user");
});
+ it("retries a same-model switch with the runtime carried by the error", async () => {
+ resolveConfiguredModelRefMock.mockReturnValue({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ });
+ const cronSession = makeCronSession({
+ sessionEntry: makeCronSessionEntry({
+ model: "gpt-5.6-luna",
+ modelProvider: "openai",
+ agentRuntimeOverride: "openclaw",
+ }),
+ isNewSession: false,
+ });
+ resolveCronSessionMock.mockReturnValue(cronSession);
+ runWithModelFallbackMock.mockImplementation(async ({ provider, model, run }) => ({
+ result: await run(provider, model),
+ provider,
+ model,
+ attempts: [],
+ }));
+ runEmbeddedAgentMock
+ .mockRejectedValueOnce(
+ new LiveSessionModelSwitchError({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntimeOverride: "codex",
+ }),
+ )
+ .mockResolvedValueOnce({
+ payloads: [{ text: "task complete" }],
+ meta: {
+ agentMeta: {
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ usage: { input: 100, output: 50 },
+ },
+ },
+ });
+
+ const result = await runCronIsolatedAgentTurn(
+ makeParams({
+ job: makeJob({
+ payload: {
+ kind: "agentTurn",
+ message: "run task",
+ model: "openai/gpt-5.6-luna",
+ },
+ }),
+ }),
+ );
+
+ expect(result.status).toBe("ok");
+ expect(requireEmbeddedAgentCall(0).agentHarnessRuntimeOverride).toBe("openclaw");
+ expect(requireEmbeddedAgentCall(1).agentHarnessRuntimeOverride).toBe("codex");
+ expect(cronSession.sessionEntry.agentRuntimeOverride).toBe("codex");
+ });
+
it("returns error (not infinite loop) when LiveSessionModelSwitchError is thrown repeatedly", async () => {
// If the runner somehow keeps throwing the same error (e.g. broken catalog)
// it should not loop forever. The inner runPrompt itself will eventually
diff --git a/src/cron/isolated-agent/run.runtime.ts b/src/cron/isolated-agent/run.runtime.ts
index 2112b671aee3..bcd68f070c02 100644
--- a/src/cron/isolated-agent/run.runtime.ts
+++ b/src/cron/isolated-agent/run.runtime.ts
@@ -10,6 +10,8 @@ export { resolveCronStyleNow } from "../../agents/current-time.js";
export { DEFAULT_CONTEXT_TOKENS } from "../../agents/defaults.js";
export { isCliProvider } from "../../agents/model-selection-cli.js";
export { resolveThinkingDefault } from "../../agents/model-thinking-default.js";
+export { resolveSessionRuntimeOverrideForProvider } from "../../agents/session-runtime-compat.js";
+export { resolveEffectiveAgentRuntime } from "../../agents/thinking-runtime.js";
export { resolveAgentTimeoutMs } from "../../agents/timeout.js";
export { deriveSessionTotalTokens, hasNonzeroUsage } from "../../agents/usage.js";
export { DEFAULT_IDENTITY_FILENAME, ensureAgentWorkspace } from "../../agents/workspace.js";
diff --git a/src/cron/isolated-agent/run.source-delivery-guard.test.ts b/src/cron/isolated-agent/run.source-delivery-guard.test.ts
index 002f225120ad..61fac5913571 100644
--- a/src/cron/isolated-agent/run.source-delivery-guard.test.ts
+++ b/src/cron/isolated-agent/run.source-delivery-guard.test.ts
@@ -400,6 +400,39 @@ describe("createCronPromptExecutor sourceDelivery guard", () => {
expect(args.forceMessageTool).toBe(true);
expect(args.messageChannel).toBe("messagechat");
});
+
+ it("forwards an explicit OpenClaw runtime override to cron execution", async () => {
+ mockRunCronFallbackPassthrough();
+ const cronSession = makeCronSession() as MutableCronSession;
+ cronSession.sessionEntry.agentRuntimeOverride = "openclaw";
+ cronSession.sessionEntry.agentHarnessId = "codex";
+ const executor = makeExecutor({
+ cfgWithAgentDefaults: {
+ agents: {
+ defaults: {
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ },
+ liveSelection: { provider: "openai", model: "gpt-5.6-luna" },
+ cronSession,
+ thinkLevel: "ultra",
+ });
+
+ await executor.runPrompt("run an Ultra task");
+
+ expect(getEmbeddedRunArg()).toEqual(
+ expect.objectContaining({
+ provider: "openai",
+ model: "gpt-5.6-luna",
+ thinkLevel: "ultra",
+ agentHarnessRuntimeOverride: "openclaw",
+ }),
+ );
+ expect(getEmbeddedRunArg()).not.toHaveProperty("agentHarnessId");
+ });
});
function makeExecuteCronRunParams(overrides: Record = {}) {
diff --git a/src/cron/isolated-agent/run.test-harness.ts b/src/cron/isolated-agent/run.test-harness.ts
index 14c5b67c7eb3..7c65d42cdfc7 100644
--- a/src/cron/isolated-agent/run.test-harness.ts
+++ b/src/cron/isolated-agent/run.test-harness.ts
@@ -54,6 +54,7 @@ export const resolveAllowedModelRefMock = createMock();
export const resolveConfiguredModelRefMock = createMock();
export const resolveHooksGmailModelMock = createMock();
export const resolveThinkingDefaultMock = createMock();
+export const resolveEffectiveAgentRuntimeMock = createMock();
export const runWithModelFallbackMock = createMock();
export const runEmbeddedAgentMock = createMock();
export const runCliAgentMock = createMock();
@@ -113,7 +114,7 @@ const getSkillsSnapshotVersionMock = createMock();
export const loadModelCatalogMock = createMock();
const getRemoteSkillEligibilityMock = createMock();
-vi.mock("./run.runtime.js", () => ({
+vi.mock("./run.runtime.js", async () => ({
resolveAgentConfig: resolveAgentConfigMock,
resolveAgentDir: vi.fn().mockReturnValue("/tmp/agent-dir"),
resolveAgentModelFallbacksOverride: resolveAgentModelFallbacksOverrideMock,
@@ -123,6 +124,12 @@ vi.mock("./run.runtime.js", () => ({
DEFAULT_CONTEXT_TOKENS: 128000,
isCliProvider: isCliProviderMock,
resolveThinkingDefault: resolveThinkingDefaultMock,
+ resolveEffectiveAgentRuntime: resolveEffectiveAgentRuntimeMock,
+ resolveSessionRuntimeOverrideForProvider: (
+ await vi.importActual(
+ "../../agents/session-runtime-compat.js",
+ )
+ ).resolveSessionRuntimeOverrideForProvider,
buildWorkspaceSkillSnapshot: buildWorkspaceSkillSnapshotMock,
getSkillsSnapshotVersion: getSkillsSnapshotVersionMock,
resolveAgentTimeoutMs: resolveAgentTimeoutMsMock,
@@ -249,6 +256,26 @@ vi.mock("./run-execution.runtime.js", () => ({
getCliSessionId: getCliSessionIdMock,
runCliAgent: runCliAgentMock,
resolveFastModeState: resolveFastModeStateMock,
+ resolveCandidateThinkingLevel: (params: {
+ provider: string;
+ modelId: string;
+ level?: string;
+ catalog?: unknown[];
+ }) => {
+ if (!params.level) {
+ return undefined;
+ }
+ const policy = {
+ provider: params.provider,
+ model: params.modelId,
+ level: params.level,
+ catalog: params.catalog,
+ agentRuntime: resolveEffectiveAgentRuntimeMock(params),
+ };
+ return isThinkingLevelSupportedMock(policy)
+ ? params.level
+ : resolveSupportedThinkingLevelMock(policy);
+ },
resolveCronAgentLane: resolveCronAgentLaneMock,
LiveSessionModelSwitchError,
runWithModelFallback: runWithModelFallbackMock,
@@ -478,6 +505,7 @@ function resetRunConfigMocks(): void {
resolveAllowedModelRefMock.mockReturnValue({ ref: { provider: "openai", model: "gpt-5.4" } });
resolveHooksGmailModelMock.mockReturnValue(null);
resolveThinkingDefaultMock.mockReturnValue("off");
+ resolveEffectiveAgentRuntimeMock.mockReturnValue("openclaw");
getModelRefStatusMock.mockReturnValue({ allowed: false });
resolveCronStyleNowMock.mockReturnValue({
formattedTime: "2026-02-10 12:00",
diff --git a/src/cron/isolated-agent/run.ts b/src/cron/isolated-agent/run.ts
index 10f06ce9db6a..53df849b706b 100644
--- a/src/cron/isolated-agent/run.ts
+++ b/src/cron/isolated-agent/run.ts
@@ -3,8 +3,8 @@ import { isDeepStrictEqual } from "node:util";
import { normalizeOptionalString } from "@openclaw/normalization-core/string-coerce";
import { retireSessionMcpRuntime } from "../../agents/agent-bundle-mcp-tools.js";
import { hasAnyAuthProfileStoreSource } from "../../agents/auth-profiles/source-check.js";
-import { resolveAgentHarnessPolicy } from "../../agents/harness/policy.js";
import { findModelInCatalog } from "../../agents/model-catalog-lookup.js";
+import type { ModelCatalogEntry } from "../../agents/model-catalog.types.js";
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../../agents/openai-routing.js";
import { createAgentRunRestartAbortError } from "../../agents/run-termination.js";
import { expandToolGroups, normalizeToolName } from "../../agents/tool-policy.js";
@@ -115,6 +115,8 @@ import {
isThinkingLevelSupported,
resolveSupportedThinkingLevel,
resolveSessionTranscriptPath,
+ resolveEffectiveAgentRuntime,
+ resolveSessionRuntimeOverrideForProvider,
resolveThinkingDefault,
setSessionRuntimeModel,
} from "./run.runtime.js";
@@ -555,6 +557,7 @@ type PreparedCronRunContext = {
inheritDefaultFallbacksForAgentStringModel: boolean;
modelFallbacksOverride?: string[];
thinkLevel: ThinkLevel | undefined;
+ thinkingCatalog: ModelCatalogEntry[];
timeoutMs: number;
preflightDiagnostics?: CronRunDiagnostics;
/**
@@ -786,29 +789,47 @@ async function prepareCronRunContext(params: {
const jobThink = normalizeThinkLevel(
(input.job.payload.kind === "agentTurn" ? input.job.payload.thinking : undefined) ?? undefined,
);
- let thinkLevel: ThinkLevel | undefined = jobThink ?? hooksGmailThinking;
- if (!thinkLevel) {
+ const sessionThink = normalizeThinkLevel(cronSession.sessionEntry.thinkingLevel);
+ const effectiveAgentRuntime = resolveEffectiveAgentRuntime({
+ cfg: cfgWithAgentDefaults,
+ provider,
+ modelId: model,
+ agentId,
+ sessionKey: agentSessionKey,
+ sessionEntry: cronSession.sessionEntry,
+ });
+ let requestedThinkLevel: ThinkLevel | undefined = jobThink ?? hooksGmailThinking ?? sessionThink;
+ if (!requestedThinkLevel) {
const thinkingCatalog = await loadCatalog();
- thinkLevel = resolveThinkingDefault({
+ requestedThinkLevel = resolveThinkingDefault({
cfg: cfgWithAgentDefaults,
provider,
model,
catalog: thinkingCatalog,
+ agentRuntime: effectiveAgentRuntime,
});
}
const thinkingCatalog = await loadCatalog();
- if (!isThinkingLevelSupported({ provider, model, level: thinkLevel, catalog: thinkingCatalog })) {
+ if (
+ !isThinkingLevelSupported({
+ provider,
+ model,
+ level: requestedThinkLevel,
+ catalog: thinkingCatalog,
+ agentRuntime: effectiveAgentRuntime,
+ })
+ ) {
const fallbackThinkLevel = resolveSupportedThinkingLevel({
provider,
model,
- level: thinkLevel,
+ level: requestedThinkLevel,
catalog: thinkingCatalog,
+ agentRuntime: effectiveAgentRuntime,
});
- if (fallbackThinkLevel !== thinkLevel) {
+ if (fallbackThinkLevel !== requestedThinkLevel) {
logWarn(
- `[cron:${input.job.id}] Thinking level "${thinkLevel}" is not supported for ${provider}/${model}; downgrading to "${fallbackThinkLevel}".`,
+ `[cron:${input.job.id}] Thinking level "${requestedThinkLevel}" is not supported for ${provider}/${model}; using "${fallbackThinkLevel}" for this candidate.`,
);
- thinkLevel = fallbackThinkLevel;
}
}
@@ -974,13 +995,7 @@ async function prepareCronRunContext(params: {
provider,
acceptedProviderIds: listOpenAIAuthProfileProvidersForAgentRuntime({
provider,
- harnessRuntime: resolveAgentHarnessPolicy({
- provider,
- modelId: model,
- config: cfgWithAgentDefaults,
- agentId,
- sessionKey: agentSessionKey,
- }).runtime,
+ harnessRuntime: effectiveAgentRuntime,
config: cfgWithAgentDefaults,
}),
agentDir,
@@ -993,6 +1008,11 @@ async function prepareCronRunContext(params: {
const liveSelection: CronLiveSelection = {
provider,
model,
+ agentRuntimeOverride: resolveSessionRuntimeOverrideForProvider({
+ provider,
+ entry: cronSession.sessionEntry,
+ cfg: cfgWithAgentDefaults,
+ }),
authProfileId,
authProfileIdSource: authProfileId
? cronSession.sessionEntry.authProfileOverrideSource
@@ -1003,7 +1023,7 @@ async function prepareCronRunContext(params: {
isFastTestEnv: params.isFastTestEnv,
cronSession,
runSessionKey,
- thinkingLevel: thinkLevel,
+ thinkingLevel: requestedThinkLevel,
toolsAllow: agentPayload?.toolsAllow,
toolsAllowIsDefault: agentPayload?.toolsAllowIsDefault,
cliSessionBindingFacts: {
@@ -1046,7 +1066,8 @@ async function prepareCronRunContext(params: {
useSubagentFallbacks,
inheritDefaultFallbacksForAgentStringModel,
modelFallbacksOverride,
- thinkLevel,
+ thinkLevel: requestedThinkLevel,
+ thinkingCatalog,
timeoutMs,
preflightDiagnostics,
runTimeoutOverrideMs,
@@ -1642,6 +1663,7 @@ export async function runCronIsolatedAgentTurn(params: {
abortReason,
isAborted,
thinkLevel: prepared.context.thinkLevel,
+ thinkingCatalog: prepared.context.thinkingCatalog,
timeoutMs: prepared.context.timeoutMs,
runTimeoutOverrideMs: prepared.context.runTimeoutOverrideMs,
suppressExecNotifyOnExit: prepared.context.suppressExecNotifyOnExit,
diff --git a/src/cron/isolated-agent/session.test.ts b/src/cron/isolated-agent/session.test.ts
index c88061b41a9e..abb7084b02ce 100644
--- a/src/cron/isolated-agent/session.test.ts
+++ b/src/cron/isolated-agent/session.test.ts
@@ -216,6 +216,23 @@ describe("resolveCronSession", () => {
expect(result.sessionEntry.pinnedAt).toBe(pinnedAt);
});
+ it("drops a standalone runtime override without a retained model selection", () => {
+ const result = resolveWithStoredEntry({
+ entry: {
+ sessionId: "existing-session-id-runtime",
+ updatedAt: NOW_MS - 1000,
+ agentRuntimeOverride: "openclaw",
+ agentHarnessId: "codex",
+ },
+ fresh: true,
+ forceNew: true,
+ });
+
+ expect(result.isNewSession).toBe(true);
+ expect(result.sessionEntry.agentRuntimeOverride).toBeUndefined();
+ expect(result.sessionEntry.agentHarnessId).toBeUndefined();
+ });
+
it("clears stale sessionFile when forceNew rolls to a fresh session", () => {
const result = resolveWithStoredEntry({
entry: {
@@ -250,6 +267,8 @@ describe("resolveCronSession", () => {
threadId: "1737500000.123456",
},
modelOverride: "gpt-5.4",
+ agentRuntimeOverride: "openclaw",
+ agentHarnessId: "codex",
},
fresh: true,
forceNew: true,
@@ -266,6 +285,8 @@ describe("resolveCronSession", () => {
expect(result.sessionEntry.deliveryContext).toBeUndefined();
// Per-session overrides must be preserved
expect(result.sessionEntry.modelOverride).toBe("gpt-5.4");
+ expect(result.sessionEntry.agentRuntimeOverride).toBe("openclaw");
+ expect(result.sessionEntry.agentHarnessId).toBeUndefined();
});
it("clears stale run-scoped state when forceNew rolls to a fresh session", () => {
@@ -429,6 +450,7 @@ describe("resolveCronSession", () => {
modelOverride: "claude-sonnet-4-6",
providerOverride: "anthropic",
modelOverrideSource: "user",
+ agentRuntimeOverride: "openclaw",
authProfileOverride: "work-profile",
authProfileOverrideSource: "user",
authProfileOverrideCompactionCount: 3,
@@ -441,6 +463,7 @@ describe("resolveCronSession", () => {
expect(result.sessionEntry.modelOverride).toBe("claude-sonnet-4-6");
expect(result.sessionEntry.providerOverride).toBe("anthropic");
expect(result.sessionEntry.modelOverrideSource).toBe("user");
+ expect(result.sessionEntry.agentRuntimeOverride).toBe("openclaw");
expect(result.sessionEntry.authProfileOverride).toBe("work-profile");
expect(result.sessionEntry.authProfileOverrideSource).toBe("user");
expect(result.sessionEntry.authProfileOverrideCompactionCount).toBe(3);
diff --git a/src/cron/isolated-agent/session.ts b/src/cron/isolated-agent/session.ts
index bccd490f2a76..781a2f53682c 100644
--- a/src/cron/isolated-agent/session.ts
+++ b/src/cron/isolated-agent/session.ts
@@ -71,8 +71,10 @@ function preserveNonAutoModelOverride(target: SessionEntry, entry: SessionEntry)
const recoveredAutoFallbackOverride =
entry.modelOverrideSource === undefined && hasSessionAutoModelFallbackProvenance(entry);
if (entry.modelOverrideSource !== "auto" && !recoveredAutoFallbackOverride) {
+ let preservedModelSelection = false;
if (entry.modelOverride !== undefined) {
target.modelOverride = entry.modelOverride;
+ preservedModelSelection = true;
}
if (entry.providerOverride !== undefined) {
target.providerOverride = entry.providerOverride;
@@ -80,6 +82,11 @@ function preserveNonAutoModelOverride(target: SessionEntry, entry: SessionEntry)
if (entry.modelOverrideSource !== undefined) {
target.modelOverrideSource = entry.modelOverrideSource;
}
+ // Runtime overrides qualify an explicit model selection; carrying one alone
+ // would pin a fresh cron session to a stale engine after its model resets.
+ if (preservedModelSelection && entry.agentRuntimeOverride !== undefined) {
+ target.agentRuntimeOverride = entry.agentRuntimeOverride;
+ }
}
}
diff --git a/src/gateway/gateway-codex-harness.live.test.ts b/src/gateway/gateway-codex-harness.live.test.ts
index f0b6cd558a35..1e8dab5ffafc 100644
--- a/src/gateway/gateway-codex-harness.live.test.ts
+++ b/src/gateway/gateway-codex-harness.live.test.ts
@@ -80,6 +80,9 @@ const CODEX_HARNESS_AGENT_TIMEOUT_SECONDS = Math.max(
);
const CODEX_HARNESS_AUTH_MODE =
process.env.OPENCLAW_LIVE_CODEX_HARNESS_AUTH === "api-key" ? "api-key" : "codex-auth";
+const CODEX_HARNESS_THINKING = resolveCodexHarnessThinkingLevel(
+ process.env.OPENCLAW_LIVE_CODEX_HARNESS_THINKING,
+);
const describeLive = LIVE && CODEX_HARNESS_LIVE ? describe : describe.skip;
const describeDisabled = LIVE && !CODEX_HARNESS_LIVE ? describe : describe.skip;
const CODEX_HARNESS_TIMEOUT_MS = 900_000;
@@ -93,12 +96,29 @@ type CapturedAgentEvent = {
};
type GuardianPluginApprovalDecision = "allow-once" | "deny";
+type CodexHarnessThinkingLevel =
+ | "off"
+ | "minimal"
+ | "low"
+ | "medium"
+ | "high"
+ | "xhigh"
+ | "max"
+ | "ultra";
function resolveLiveTimeoutMs(raw: string | undefined, fallback: number): number {
const parsed = raw ? Number(raw) : Number.NaN;
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}
+function resolveCodexHarnessThinkingLevel(raw: string | undefined): CodexHarnessThinkingLevel {
+ const normalized = raw?.trim().toLowerCase() || "low";
+ if (!["off", "minimal", "low", "medium", "high", "xhigh", "max", "ultra"].includes(normalized)) {
+ throw new Error(`invalid OPENCLAW_LIVE_CODEX_HARNESS_THINKING: ${raw}`);
+ }
+ return normalized as CodexHarnessThinkingLevel;
+}
+
function logCodexLiveStep(step: string, details?: Record): void {
if (!CODEX_HARNESS_DEBUG) {
return;
@@ -202,6 +222,53 @@ function parseModelKey(modelKey: string): { provider: string; modelId: string }
return { provider: provider.trim(), modelId: modelId.trim() };
}
+async function assertCodexHarnessSessionSelection(params: {
+ client: GatewayClient;
+ modelKey: string;
+ sessionKey: string;
+}): Promise {
+ const expected = parseModelKey(params.modelKey);
+ const result: {
+ sessions?: Array<{
+ key?: string;
+ model?: string;
+ modelProvider?: string;
+ agentRuntime?: { id?: string };
+ thinkingLevel?: string;
+ }>;
+ } = await params.client.request("sessions.list", {
+ includeGlobal: true,
+ limit: 200,
+ });
+ const row = result.sessions?.find((entry) => entry.key === params.sessionKey);
+ expect(row, `expected sessions.list row for ${params.sessionKey}`).toBeDefined();
+ expect(row?.modelProvider).toBe(expected.provider);
+ expect(row?.model).toBe(expected.modelId);
+ expect(row?.agentRuntime?.id).toBe("codex");
+ expect(row?.thinkingLevel).toBe(CODEX_HARNESS_THINKING);
+}
+
+async function assertCodexHarnessTranscriptModelIdentity(params: {
+ client: GatewayClient;
+ modelKey: string;
+ sessionKey: string;
+}): Promise {
+ const expected = parseModelKey(params.modelKey);
+ const history: { messages?: unknown[] } = await params.client.request("chat.history", {
+ sessionKey: params.sessionKey,
+ limit: 50,
+ });
+ const assistant = (history.messages ?? []).findLast(
+ (message) =>
+ message !== null &&
+ typeof message === "object" &&
+ (message as { role?: unknown }).role === "assistant",
+ ) as { model?: unknown; provider?: unknown } | undefined;
+ expect(assistant, `expected assistant transcript entry for ${params.sessionKey}`).toBeDefined();
+ expect(assistant?.provider).toBe(expected.provider);
+ expect(assistant?.model).toBe(expected.modelId);
+}
+
async function writeLiveGatewayConfig(params: {
codexAppServerMode?: "guardian" | "yolo";
codeModeOnly?: boolean;
@@ -239,6 +306,7 @@ async function writeLiveGatewayConfig(params: {
workspace: params.workspace,
skipBootstrap: true,
timeoutSeconds: CODEX_HARNESS_AGENT_TIMEOUT_SECONDS,
+ thinkingDefault: CODEX_HARNESS_THINKING,
model: { primary: params.modelKey },
models: { [params.modelKey]: { agentRuntime: { id: "codex" } } },
sandbox: { mode: "off" },
@@ -248,6 +316,7 @@ async function writeLiveGatewayConfig(params: {
id: "dev",
default: true,
workspace: params.workspace,
+ thinkingDefault: CODEX_HARNESS_THINKING,
model: { primary: params.modelKey },
models: { [params.modelKey]: { agentRuntime: { id: "codex" } } },
},
@@ -290,7 +359,7 @@ async function requestAgentTextWithEvents(params: {
idempotencyKey: `idem-${randomUUID()}-codex-guardian`,
message: params.message,
deliver: false,
- thinking: "low",
+ thinking: CODEX_HARNESS_THINKING,
timeout: CODEX_HARNESS_AGENT_TIMEOUT_SECONDS,
},
{ expectFinal: true, timeoutMs: CODEX_HARNESS_REQUEST_TIMEOUT_MS },
@@ -312,13 +381,29 @@ async function requestAgentText(params: {
message: string;
sessionKey: string;
}): Promise {
- const { text } = await requestAgentTextWithEvents({
+ const { text, events } = await requestAgentTextWithEvents({
client: params.client,
eventPrefix: "codex_app_server.",
message: params.message,
sessionKey: params.sessionKey,
});
expect(text).toContain(params.expectedToken);
+ const turnStarting = events.find(
+ (event) =>
+ event.stream === "codex_app_server.lifecycle" && event.data?.phase === "turn_starting",
+ );
+ expect(
+ turnStarting,
+ `expected an actual Codex app-server turn for ${params.sessionKey}; events=${JSON.stringify(events)}`,
+ ).toBeDefined();
+ const expectedModel = parseModelKey(
+ process.env.OPENCLAW_LIVE_CODEX_HARNESS_MODEL ?? DEFAULT_CODEX_MODEL,
+ ).modelId;
+ expect(turnStarting?.data).toMatchObject({
+ model: expectedModel,
+ effort: CODEX_HARNESS_THINKING,
+ collaborationEffort: CODEX_HARNESS_THINKING,
+ });
return text;
}
@@ -581,7 +666,7 @@ async function verifyCodexImageProbe(params: {
},
],
deliver: false,
- thinking: "low",
+ thinking: CODEX_HARNESS_THINKING,
timeout: CODEX_HARNESS_AGENT_TIMEOUT_SECONDS,
},
{ expectFinal: true, timeoutMs: CODEX_HARNESS_REQUEST_TIMEOUT_MS },
@@ -800,7 +885,7 @@ async function verifyCodexCronMcpProbe(params: {
exactReply: cronProbe.name,
}),
deliver: false,
- thinking: "low",
+ thinking: CODEX_HARNESS_THINKING,
},
{ expectFinal: true, timeoutMs: CODEX_HARNESS_REQUEST_TIMEOUT_MS },
);
@@ -941,7 +1026,7 @@ async function verifyCodexSubagentProbe(params: {
{
task: `Reply exactly ${expectedToken} and nothing else.`,
agentId: "dev",
- thinking: "low",
+ thinking: CODEX_HARNESS_THINKING,
mode: "run",
cleanup: "keep",
context: "isolated",
@@ -1145,6 +1230,26 @@ describeLive("gateway live (Codex harness)", () => {
for (let attempt = 1; attempt <= maxAttempts; attempt += 1) {
try {
const sessionKey = "agent:dev:live-codex-harness";
+ const modelCommandText = await requestCodexCommandText({
+ client: activeClient,
+ events: gatewayEvents,
+ sessionKey,
+ command: `/model ${modelKey} --runtime codex`,
+ expectedText: [modelKey, "Runtime set to codex"],
+ isExpectedText: (text) =>
+ text.includes(modelKey) && text.includes("Runtime set to codex"),
+ predicateOnly: true,
+ });
+ logCodexLiveStep("model-command", { modelCommandText });
+ await activeClient.request("sessions.patch", {
+ key: sessionKey,
+ thinkingLevel: CODEX_HARNESS_THINKING,
+ });
+ await assertCodexHarnessSessionSelection({
+ client: activeClient,
+ modelKey,
+ sessionKey,
+ });
if (CODEX_HARNESS_SUBAGENT_PROBE) {
logCodexLiveStep("subagent-probe:start", { sessionKey });
@@ -1180,6 +1285,16 @@ describeLive("gateway live (Codex harness)", () => {
});
expect(secondText).toContain(secondToken);
logCodexLiveStep("second-turn", { secondText });
+ await assertCodexHarnessSessionSelection({
+ client: activeClient,
+ modelKey,
+ sessionKey,
+ });
+ await assertCodexHarnessTranscriptModelIdentity({
+ client: activeClient,
+ modelKey,
+ sessionKey,
+ });
if (CODEX_HARNESS_CODE_MODE_ONLY) {
logCodexLiveStep("code-mode-only-tool-probe:start", { sessionKey });
@@ -1254,10 +1369,10 @@ describeLive("gateway live (Codex harness)", () => {
break;
} catch (error) {
if (isCodexAccountTokenError(error)) {
- console.error(
- "SKIP: Codex auth cannot extract accountId from the available token; skipping live Codex harness assertions.",
+ throw new Error(
+ "Codex auth cannot extract accountId from the available token; refresh auth or use API-key mode",
+ { cause: error },
);
- return;
}
if (
attempt < maxAttempts &&
diff --git a/src/gateway/gateway-models.profiles.live.test.ts b/src/gateway/gateway-models.profiles.live.test.ts
index 92bbc2d25be5..2801f17e9003 100644
--- a/src/gateway/gateway-models.profiles.live.test.ts
+++ b/src/gateway/gateway-models.profiles.live.test.ts
@@ -2,8 +2,15 @@
import { randomBytes, randomUUID } from "node:crypto";
import { existsSync, readFileSync, writeSync } from "node:fs";
import fs from "node:fs/promises";
+import {
+ createServer as createHttpServer,
+ type IncomingMessage,
+ type ServerResponse,
+} from "node:http";
import os from "node:os";
import path from "node:path";
+import { Readable } from "node:stream";
+import { pipeline } from "node:stream/promises";
import {
clampThinkingLevel,
type Api,
@@ -79,6 +86,20 @@ const REQUIRE_PROFILE_KEYS = isLiveProfileKeyModeEnabled();
const LIVE_CREDENTIAL_PRECEDENCE = REQUIRE_PROFILE_KEYS ? "profile-first" : "env-first";
const PROVIDERS = parseFilter(process.env.OPENCLAW_LIVE_GATEWAY_PROVIDERS);
const GATEWAY_LIVE_SMOKE = isTruthyEnvValue(process.env.OPENCLAW_LIVE_GATEWAY_SMOKE);
+const GATEWAY_LIVE_OPENAI_API_DEFAULT = isTruthyEnvValue(
+ process.env.OPENCLAW_LIVE_GATEWAY_OPENAI_API_DEFAULT,
+);
+const GATEWAY_LIVE_THINKING_LEVELS = [
+ "off",
+ "minimal",
+ "low",
+ "medium",
+ "high",
+ "xhigh",
+ "max",
+ "ultra",
+] as const;
+type GatewayLiveThinkingLevel = (typeof GATEWAY_LIVE_THINKING_LEVELS)[number];
const THINKING_LEVEL = resolveGatewayLiveThinkingLevel({
raw: process.env.OPENCLAW_LIVE_GATEWAY_THINKING,
smoke: GATEWAY_LIVE_SMOKE,
@@ -1659,11 +1680,61 @@ describe("providerScopedModelRegistryProviders", () => {
describe("resolveGatewayLiveModelThinkingLevel", () => {
it("allows release lanes to lower gateway live thinking without smoke mode", () => {
expect(resolveGatewayLiveThinkingLevel({ raw: "low", smoke: false })).toBe("low");
+ expect(resolveGatewayLiveThinkingLevel({ raw: "max", smoke: false })).toBe("max");
+ expect(resolveGatewayLiveThinkingLevel({ raw: "ULTRA", smoke: false })).toBe("ultra");
expect(resolveGatewayLiveThinkingLevel({ raw: undefined, smoke: false })).toBe("high");
expect(resolveGatewayLiveThinkingLevel({ raw: undefined, smoke: true })).toBe("low");
expect(resolveGatewayLiveThinkingLevel({ raw: "wat", smoke: false })).toBe("high");
});
+ it.each(["gpt-5.6", "gpt-5.6-sol", "gpt-5.6-terra", "gpt-5.6-luna"])(
+ "preserves OpenClaw Ultra for openai/%s",
+ (id) => {
+ expect(
+ resolveGatewayLiveModelThinkingLevel({
+ cfg: {},
+ model: {
+ ...createGatewayLiveTestModel("openai", id),
+ reasoning: true,
+ thinkingLevelMap: { off: null, max: "max" },
+ },
+ requestedLevel: "ultra",
+ }),
+ ).toBe("ultra");
+ },
+ );
+
+ it("preserves exact max for max-capable GPT-5.6 metadata", () => {
+ expect(
+ resolveGatewayLiveModelThinkingLevel({
+ cfg: {},
+ model: {
+ ...createGatewayLiveTestModel("openai", "gpt-5.6-sol"),
+ reasoning: true,
+ thinkingLevelMap: { off: null, max: "max" },
+ },
+ requestedLevel: "max",
+ }),
+ ).toBe("max");
+ });
+
+ it("fails exact-proof levels instead of silently clamping them", () => {
+ expect(() =>
+ resolveGatewayLiveModelThinkingLevel({
+ cfg: {},
+ model: createGatewayLiveTestModel("openai", "gpt-5.5"),
+ requestedLevel: "max",
+ }),
+ ).toThrow(/does not advertise max|clamps max/u);
+ expect(() =>
+ resolveGatewayLiveModelThinkingLevel({
+ cfg: {},
+ model: createGatewayLiveTestModel("openai", "gpt-5.5"),
+ requestedLevel: "ultra",
+ }),
+ ).toThrow(/does not advertise ultra/u);
+ });
+
it("clamps requested thinking to levels supported by model metadata", () => {
expect(
resolveGatewayLiveModelThinkingLevel({
@@ -2941,6 +3012,144 @@ async function readSessionAssistantTexts(
).map((entry) => entry.text);
}
+async function assertGatewayLiveSessionSelection(params: {
+ client: GatewayClient;
+ expectedModelId: string;
+ expectedProvider: string;
+ expectedThinkingLevel?: string;
+ sessionKey: string;
+}): Promise {
+ const result: {
+ sessions?: Array<{
+ key?: string;
+ model?: string;
+ modelProvider?: string;
+ thinkingLevel?: string;
+ }>;
+ } = await params.client.request("sessions.list", {
+ includeGlobal: true,
+ limit: 200,
+ });
+ const row = result.sessions?.find((entry) => entry.key === params.sessionKey);
+ expect(row, `expected sessions.list row for ${params.sessionKey}`).toBeDefined();
+ expect(row?.modelProvider).toBe(params.expectedProvider);
+ expect(row?.model).toBe(params.expectedModelId);
+ if (params.expectedThinkingLevel) {
+ expect(row?.thinkingLevel).toBe(params.expectedThinkingLevel);
+ }
+}
+
+async function assertGatewayLiveTranscriptModelIdentity(params: {
+ expectedModelId: string;
+ expectedProvider: string;
+ sessionKey: string;
+}): Promise {
+ const messages = await readSessionMessagesForLiveProbe(params.sessionKey);
+ const assistant = messages.findLast(
+ (message) =>
+ message !== null &&
+ typeof message === "object" &&
+ (message as { role?: unknown }).role === "assistant",
+ ) as { model?: unknown; provider?: unknown } | undefined;
+ expect(assistant, `expected assistant transcript entry for ${params.sessionKey}`).toBeDefined();
+ expect(assistant?.provider).toBe(params.expectedProvider);
+ expect(assistant?.model).toBe(params.expectedModelId);
+}
+
+async function verifyGatewayUltraSubagentHandoff(params: {
+ client: GatewayClient;
+ expectedModelId: string;
+ expectedProvider: string;
+ modelKey: string;
+ sessionKey: string;
+ thinkingLevel: string;
+}): Promise {
+ const { listSubagentRunsForRequester } = await import("../agents/subagent-registry.js");
+ const existingRunIds = new Set(
+ listSubagentRunsForRequester(params.sessionKey).map((entry) => entry.runId),
+ );
+ const nonce = randomBytes(3).toString("hex").toUpperCase();
+ const childToken = `ULTRA-CHILD-${nonce}`;
+ const parentToken = `ULTRA-PARENT-${nonce}`;
+ const message = [
+ "Ultra orchestration live proof.",
+ "Call sessions_spawn exactly once with these exact arguments:",
+ JSON.stringify({
+ task: `Reply exactly ${childToken} and nothing else.`,
+ agentId: GATEWAY_LIVE_AGENT_ID,
+ mode: "run",
+ cleanup: "keep",
+ model: params.modelKey,
+ thinking: params.thinkingLevel,
+ }),
+ "Wait for the child completion to return before answering.",
+ `Then reply exactly ${parentToken} ${childToken} and nothing else.`,
+ ].join("\n");
+ const text = await requestGatewayAgentText({
+ client: params.client,
+ sessionKey: params.sessionKey,
+ idempotencyKey: `idem-${randomUUID()}-ultra-subagent`,
+ modelKey: params.modelKey,
+ message,
+ thinkingLevel: params.thinkingLevel,
+ context: `${params.modelKey}: ultra-subagent-handoff`,
+ });
+ expect(text).toContain(parentToken);
+ expect(text).toContain(childToken);
+
+ const deadline = Date.now() + GATEWAY_LIVE_PROBE_TIMEOUT_MS;
+ let run = listSubagentRunsForRequester(params.sessionKey).find(
+ (entry) => !existingRunIds.has(entry.runId) && entry.task.includes(childToken),
+ );
+ while ((!run?.endedAt || run.delivery?.status !== "delivered") && Date.now() < deadline) {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 250);
+ });
+ run = listSubagentRunsForRequester(params.sessionKey).find(
+ (entry) => !existingRunIds.has(entry.runId) && entry.task.includes(childToken),
+ );
+ }
+ const matchingRuns = listSubagentRunsForRequester(params.sessionKey).filter(
+ (entry) => !existingRunIds.has(entry.runId) && entry.task.includes(childToken),
+ );
+ expect(
+ matchingRuns,
+ `expected exactly one sessions_spawn child for ${params.modelKey}`,
+ ).toHaveLength(1);
+ run = matchingRuns[0];
+ expect(run, `expected sessions_spawn child for ${params.modelKey}`).toBeDefined();
+ expect(run?.outcome?.status).toBe("ok");
+ expect(run?.completion?.resultText).toContain(childToken);
+ expect(run?.delivery?.status).toBe("delivered");
+ expect(run?.childSessionKey).toContain(":subagent:");
+ expect(run?.model).toBe(params.modelKey);
+ if (run?.childSessionKey) {
+ await assertGatewayLiveSessionSelection({
+ client: params.client,
+ sessionKey: run.childSessionKey,
+ expectedProvider: params.expectedProvider,
+ expectedModelId: params.expectedModelId,
+ expectedThinkingLevel: params.thinkingLevel,
+ });
+ await assertGatewayLiveTranscriptModelIdentity({
+ sessionKey: run.childSessionKey,
+ expectedProvider: params.expectedProvider,
+ expectedModelId: params.expectedModelId,
+ });
+ }
+ const childMessages = run?.childSessionKey
+ ? await readSessionMessagesForLiveProbe(run.childSessionKey)
+ : [];
+ expect(
+ childMessages.some(
+ (childMessage) =>
+ (childMessage as { role?: unknown } | null | undefined)?.role === "assistant" &&
+ extractTranscriptMessageText(childMessage).includes(childToken),
+ ),
+ `expected child transcript handoff token ${childToken}`,
+ ).toBe(true);
+}
+
function latestAssistantTextAfterBaseline(
assistantTexts: string[],
baselineAssistantCount: number,
@@ -3289,6 +3498,330 @@ type GatewayModelSuiteParams = {
providerOverrides?: Record;
};
+type OpenAIUltraWireObservation = {
+ model?: string;
+ reasoningEffort?: string;
+};
+
+type OpenAIUltraWireCapture = {
+ baseUrl: string;
+ close: () => Promise;
+ observations: OpenAIUltraWireObservation[];
+};
+
+function isOpenAIGpt56UltraTarget(model: Model, thinkingLevel: string): boolean {
+ if (thinkingLevel !== "ultra" || normalizeProviderId(model.provider) !== "openai") {
+ return false;
+ }
+ const id = model.id.trim().toLowerCase();
+ return (
+ id === "gpt-5.6" || id === "gpt-5.6-sol" || id === "gpt-5.6-terra" || id === "gpt-5.6-luna"
+ );
+}
+
+function readOpenAIUltraWireObservation(body: Buffer): OpenAIUltraWireObservation {
+ try {
+ const parsed = JSON.parse(body.toString("utf8")) as {
+ model?: unknown;
+ reasoning?: { effort?: unknown };
+ };
+ return {
+ ...(typeof parsed.model === "string" ? { model: parsed.model } : {}),
+ ...(typeof parsed.reasoning?.effort === "string"
+ ? { reasoningEffort: parsed.reasoning.effort }
+ : {}),
+ };
+ } catch {
+ return {};
+ }
+}
+
+async function startOpenAIUltraWireCapture(
+ upstreamBaseUrl: string,
+): Promise {
+ const upstream = new URL(upstreamBaseUrl);
+ const observations: OpenAIUltraWireObservation[] = [];
+ const activeUpstreamRequests = new Set();
+ // Retain only model/effort evidence. Forward auth to the model's original
+ // origin without logging or storing headers, bodies, or response content.
+ const handleRequest = async (
+ request: IncomingMessage,
+ response: ServerResponse,
+ ): Promise => {
+ const upstreamAbort = new AbortController();
+ const abortUpstream = () => upstreamAbort.abort();
+ const abortOnPrematureResponseClose = () => {
+ if (!response.writableEnded) {
+ abortUpstream();
+ }
+ };
+ activeUpstreamRequests.add(upstreamAbort);
+ request.once("aborted", abortUpstream);
+ response.once("close", abortOnPrematureResponseClose);
+ try {
+ const chunks: Buffer[] = [];
+ for await (const chunk of request) {
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
+ }
+ const body = Buffer.concat(chunks);
+ observations.push(readOpenAIUltraWireObservation(body));
+
+ const headers = new Headers();
+ for (const [name, value] of Object.entries(request.headers)) {
+ if (
+ value === undefined ||
+ name === "host" ||
+ name === "connection" ||
+ name === "content-length"
+ ) {
+ continue;
+ }
+ headers.set(name, Array.isArray(value) ? value.join(", ") : value);
+ }
+ const upstreamResponse = await fetch(new URL(request.url ?? "/", upstream.origin), {
+ method: request.method,
+ headers,
+ body: request.method === "GET" || request.method === "HEAD" ? undefined : body,
+ redirect: "manual",
+ signal: upstreamAbort.signal,
+ });
+ const responseHeaders: Record = {};
+ upstreamResponse.headers.forEach((value, name) => {
+ if (
+ name !== "connection" &&
+ name !== "content-encoding" &&
+ name !== "content-length" &&
+ name !== "transfer-encoding"
+ ) {
+ responseHeaders[name] = value;
+ }
+ });
+ response.writeHead(upstreamResponse.status, responseHeaders);
+ if (upstreamResponse.body) {
+ // Pipeline couples backpressure and downstream closure to the upstream
+ // stream instead of buffering an abandoned or slow SSE response.
+ await pipeline(Readable.from(upstreamResponse.body), response);
+ } else {
+ response.end();
+ }
+ } catch (error) {
+ if (response.destroyed) {
+ return;
+ }
+ if (response.headersSent) {
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
+ return;
+ }
+ response.writeHead(502, { "content-type": "application/json" });
+ response.end(
+ JSON.stringify({ error: error instanceof Error ? error.message : String(error) }),
+ );
+ } finally {
+ activeUpstreamRequests.delete(upstreamAbort);
+ request.off("aborted", abortUpstream);
+ response.off("close", abortOnPrematureResponseClose);
+ }
+ };
+ const server = createHttpServer((request, response) => {
+ void handleRequest(request, response).catch((error: unknown) => {
+ if (!response.destroyed) {
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
+ }
+ });
+ });
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolve);
+ });
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ await new Promise((resolve) => {
+ server.close(() => resolve());
+ });
+ throw new Error("failed to start OpenAI Ultra wire capture proxy");
+ }
+ let closePromise: Promise | undefined;
+ return {
+ baseUrl: `http://127.0.0.1:${address.port}${upstream.pathname.replace(/\/$/u, "")}`,
+ observations,
+ close: () => {
+ closePromise ??= (async () => {
+ for (const controller of activeUpstreamRequests) {
+ controller.abort();
+ }
+ await new Promise((resolve, reject) => {
+ let settled = false;
+ const finish = (error?: Error) => {
+ if (settled) {
+ return;
+ }
+ settled = true;
+ clearTimeout(forceCloseTimer);
+ if (error) {
+ reject(error);
+ } else {
+ resolve();
+ }
+ };
+ const forceCloseTimer = setTimeout(() => {
+ server.closeAllConnections();
+ finish();
+ }, 5_000);
+ server.close((error) => finish(error));
+ server.closeIdleConnections();
+ });
+ })();
+ return closePromise;
+ },
+ };
+}
+
+async function listenOnLoopbackForUltraWireTest(
+ server: ReturnType,
+): Promise {
+ await new Promise((resolve, reject) => {
+ server.once("error", reject);
+ server.listen(0, "127.0.0.1", resolve);
+ });
+ const address = server.address();
+ if (!address || typeof address === "string") {
+ throw new Error("failed to start Ultra wire test server");
+ }
+ return `http://127.0.0.1:${address.port}/v1`;
+}
+
+async function closeUltraWireTestServer(
+ server: ReturnType,
+): Promise {
+ server.closeAllConnections();
+ await new Promise((resolve, reject) => {
+ server.close((error) => (error ? reject(error) : resolve()));
+ });
+}
+
+describe("OpenAI Ultra wire capture", () => {
+ it("forwards streaming responses while retaining only model and effort evidence", async () => {
+ let upstreamAuthorization: string | undefined;
+ const upstream = createHttpServer((request, response) => {
+ void (async () => {
+ upstreamAuthorization = request.headers.authorization;
+ for await (const chunk of request) {
+ // Drain the request before responding, like the Responses API.
+ Buffer.byteLength(chunk);
+ }
+ response.writeHead(200, { "content-type": "text/event-stream" });
+ response.write("data: first\n\n");
+ response.end("data: done\n\n");
+ })().catch((error: unknown) => {
+ response.destroy(error instanceof Error ? error : new Error(String(error)));
+ });
+ });
+ const upstreamBaseUrl = await listenOnLoopbackForUltraWireTest(upstream);
+ const capture = await startOpenAIUltraWireCapture(upstreamBaseUrl);
+ try {
+ const response = await fetch(`${capture.baseUrl}/responses`, {
+ method: "POST",
+ headers: { "content-type": "application/json", authorization: "Bearer redacted" },
+ body: JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "max" } }),
+ });
+ expect(response.status).toBe(200);
+ expect(await response.text()).toBe("data: first\n\ndata: done\n\n");
+ expect(capture.observations).toEqual([{ model: "gpt-5.6-sol", reasoningEffort: "max" }]);
+ expect(upstreamAuthorization).toBe("Bearer redacted");
+ expect(JSON.stringify(capture.observations)).not.toContain("redacted");
+ } finally {
+ try {
+ await capture.close();
+ } finally {
+ await closeUltraWireTestServer(upstream);
+ }
+ }
+ });
+
+ it("aborts an active upstream stream during bounded close", async () => {
+ let resolveUpstreamClosed: (() => void) | undefined;
+ const upstreamClosed = new Promise((resolve) => {
+ resolveUpstreamClosed = resolve;
+ });
+ const upstream = createHttpServer((_request, response) => {
+ response.once("close", () => resolveUpstreamClosed?.());
+ response.writeHead(200, { "content-type": "text/event-stream" });
+ response.write("data: open\n\n");
+ });
+ const upstreamBaseUrl = await listenOnLoopbackForUltraWireTest(upstream);
+ const capture = await startOpenAIUltraWireCapture(upstreamBaseUrl);
+ try {
+ const response = await fetch(`${capture.baseUrl}/responses`, {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ model: "gpt-5.6-sol", reasoning: { effort: "max" } }),
+ });
+ expect(response.status).toBe(200);
+ let timeoutHandle: ReturnType | undefined;
+ const closeResult = await Promise.race([
+ capture.close().then(() => "closed" as const),
+ new Promise<"timed-out">((resolve) => {
+ timeoutHandle = setTimeout(() => resolve("timed-out"), 2_000);
+ }),
+ ]);
+ clearTimeout(timeoutHandle);
+ expect(closeResult).toBe("closed");
+ await upstreamClosed;
+ await response.body?.cancel().catch(() => undefined);
+ } finally {
+ try {
+ await capture.close();
+ } finally {
+ await closeUltraWireTestServer(upstream);
+ }
+ }
+ });
+});
+
+function buildOpenAIUltraWireProviderOverride(params: {
+ baseUrl: string;
+ candidates: Array;
+ cfg: OpenClawConfig;
+}): ModelProviderConfig {
+ const discovered = buildLiveProviderConfigs({
+ candidates: params.candidates,
+ cfg: params.cfg,
+ }).openai;
+ if (!discovered) {
+ throw new Error("missing OpenAI provider config for Ultra wire capture");
+ }
+ const merged = mergeLiveProviderConfig({
+ provider: "openai",
+ base: params.cfg.models?.providers?.openai,
+ discovered,
+ });
+ return {
+ ...merged,
+ baseUrl: params.baseUrl,
+ models: merged.models?.map((model) => Object.assign({}, model, { baseUrl: params.baseUrl })),
+ };
+}
+
+function assertOpenAIUltraWireEffort(params: {
+ expectedModel: string;
+ observations: OpenAIUltraWireObservation[];
+}): number {
+ const matching = params.observations.filter((entry) => entry.model === params.expectedModel);
+ expect(
+ matching.length,
+ `expected captured OpenAI requests for ${params.expectedModel}; observations=${JSON.stringify(
+ params.observations,
+ )}`,
+ ).toBeGreaterThan(0);
+ expect(
+ matching.every((entry) => entry.reasoningEffort === "max"),
+ `expected Ultra to use wire effort=max for ${params.expectedModel}; observations=${JSON.stringify(
+ matching,
+ )}`,
+ ).toBe(true);
+ return matching.length;
+}
+
type LiveModelRegistry = ModelRegistry;
function toGatewayLiveModel(params: {
@@ -3599,8 +4132,8 @@ function resolveGatewayLiveModelThinkingLevel(params: {
requestedLevel: string;
}): string {
const { model, requestedLevel } = params;
- const normalized = requestedLevel.trim() as ModelThinkingLevel;
- if (!["off", "minimal", "low", "medium", "high", "xhigh"].includes(normalized)) {
+ const normalized = requestedLevel.trim().toLowerCase();
+ if (!isGatewayLiveThinkingLevel(normalized)) {
return requestedLevel;
}
const profile = resolveProviderThinkingProfile({
@@ -3609,14 +4142,27 @@ function resolveGatewayLiveModelThinkingLevel(params: {
context: {
provider: model.provider,
modelId: model.id,
+ agentRuntime: "openclaw",
reasoning: model.reasoning,
compat: getProviderThinkingModelCompat(model),
},
});
if (profile) {
const levelIds = profile.levels.map((level) => level.id);
- if (levelIds.includes(normalized)) {
- return clampThinkingLevel(model, normalized);
+ if (levelIds.some((level) => level === normalized)) {
+ if (normalized === "ultra") {
+ return normalized;
+ }
+ const clamped = clampThinkingLevel(model, normalized as ModelThinkingLevel);
+ if (normalized === "max" && clamped !== normalized) {
+ throw new Error(
+ `${model.provider}/${model.id} advertises max but model metadata clamps it to ${clamped}`,
+ );
+ }
+ return clamped;
+ }
+ if (normalized === "max" || normalized === "ultra") {
+ throw new Error(`${model.provider}/${model.id} does not advertise ${normalized}`);
}
if (profile.defaultLevel) {
return clampThinkingLevel(model, profile.defaultLevel as ModelThinkingLevel);
@@ -3628,7 +4174,14 @@ function resolveGatewayLiveModelThinkingLevel(params: {
: requestedLevel;
}
}
- return clampThinkingLevel(model, normalized);
+ if (normalized === "ultra") {
+ throw new Error(`${model.provider}/${model.id} does not advertise ultra`);
+ }
+ const clamped = clampThinkingLevel(model, normalized as ModelThinkingLevel);
+ if (normalized === "max" && clamped !== normalized) {
+ throw new Error(`${model.provider}/${model.id} clamps max to ${clamped}`);
+ }
+ return clamped;
}
function getProviderThinkingModelCompat(model: Model): ProviderThinkingModelCompat | undefined {
@@ -3659,11 +4212,50 @@ function resolveGatewayLiveThinkingLevel(params: { raw?: string; smoke: boolean
if (!raw) {
return params.smoke ? "low" : "high";
}
- return ["off", "minimal", "low", "medium", "high", "xhigh"].includes(raw)
- ? raw
- : params.smoke
- ? "low"
- : "high";
+ return isGatewayLiveThinkingLevel(raw) ? raw : params.smoke ? "low" : "high";
+}
+
+async function resolveGatewayLiveRequestedModels(): Promise {
+ const configured = process.env.OPENCLAW_LIVE_GATEWAY_MODELS?.trim();
+ if (!GATEWAY_LIVE_OPENAI_API_DEFAULT) {
+ return configured;
+ }
+ if (configured) {
+ throw new Error(
+ "OPENCLAW_LIVE_GATEWAY_OPENAI_API_DEFAULT cannot be combined with OPENCLAW_LIVE_GATEWAY_MODELS",
+ );
+ }
+ if (!PROVIDERS || PROVIDERS.size !== 1 || !PROVIDERS.has("openai")) {
+ throw new Error(
+ "OPENCLAW_LIVE_GATEWAY_OPENAI_API_DEFAULT requires OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai",
+ );
+ }
+ const apiKey = process.env.OPENAI_API_KEY?.trim();
+ if (!apiKey) {
+ throw new Error("OPENCLAW_LIVE_GATEWAY_OPENAI_API_DEFAULT requires OPENAI_API_KEY");
+ }
+ const { detectInferenceBackends } = await import("../commands/onboard-inference.js");
+ const candidates = await detectInferenceBackends({
+ config: {},
+ env: { OPENAI_API_KEY: apiKey },
+ platform: "linux",
+ deps: {
+ probeLocalCommand: async (command) => ({ command, found: false }),
+ readClaudeCliCredentials: () => null,
+ readCodexCliCredentials: () => null,
+ readGeminiCliCredentials: () => null,
+ },
+ });
+ const selected = candidates.find((candidate) => candidate.kind === "openai-api-key");
+ if (!selected) {
+ throw new Error("fresh OpenAI API-key inference selection returned no candidate");
+ }
+ expect(selected.modelRef).toBe("openai/gpt-5.6");
+ return selected.modelRef;
+}
+
+function isGatewayLiveThinkingLevel(value: string): value is GatewayLiveThinkingLevel {
+ return GATEWAY_LIVE_THINKING_LEVELS.some((level) => level === value);
}
function buildLiveGatewayConfig(params: {
@@ -3808,8 +4400,25 @@ function buildMinimaxProviderOverride(params: {
}
async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
- clearRuntimeConfigSnapshot();
- const runtimeEnv = enterProductionEnvForLiveRun();
+ const ultraCandidates = params.candidates.filter((model) =>
+ isOpenAIGpt56UltraTarget(model, params.thinkingLevel),
+ );
+ if (ultraCandidates.length > 0 && ultraCandidates.length !== params.candidates.length) {
+ throw new Error(
+ "OPENCLAW_LIVE_GATEWAY_THINKING=ultra requires an explicit GPT-5.6 OpenAI model list",
+ );
+ }
+ const ultraUpstreamBaseUrls = new Set(
+ ultraCandidates.map((model) => model.baseUrl?.trim()).filter(Boolean),
+ );
+ if (ultraCandidates.length > 0 && ultraUpstreamBaseUrls.size !== 1) {
+ throw new Error(
+ `Ultra wire capture requires one explicit OpenAI base URL; found ${JSON.stringify([
+ ...ultraUpstreamBaseUrls,
+ ])}`,
+ );
+ }
+ const [ultraUpstreamBaseUrl] = [...ultraUpstreamBaseUrls];
const previous = {
configPath: process.env.OPENCLAW_CONFIG_PATH,
token: process.env.OPENCLAW_GATEWAY_TOKEN,
@@ -3822,100 +4431,128 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
agentDir: process.env.OPENCLAW_AGENT_DIR,
stateDir: process.env.OPENCLAW_STATE_DIR,
};
-
- process.env.OPENCLAW_SKIP_CHANNELS = "1";
- process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
- process.env.OPENCLAW_SKIP_CRON = "1";
- process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
- if (QUIET_LIVE_LOGS) {
- process.env.OPENCLAW_DISABLE_BONJOUR = "1";
- process.env.OPENCLAW_LOG_LEVEL = "silent";
- }
-
- const token = `test-${randomUUID()}`;
- process.env.OPENCLAW_GATEWAY_TOKEN = token;
- const agentId = GATEWAY_LIVE_AGENT_ID;
-
- const hostAgentDir = resolveDefaultAgentDir(getRuntimeConfig());
- const hostStore = ensureAuthProfileStore(hostAgentDir, {
- allowKeychainPrompt: false,
- });
- const sanitizedStore = sanitizeAuthProfileStoreForLiveGateway({
- version: hostStore.version,
- profiles: { ...hostStore.profiles },
- // Keep selection state so the gateway picks the same known-good profiles
- // as the host (important when some profiles are rate-limited/disabled).
- order: hostStore.order ? { ...hostStore.order } : undefined,
- lastGood: hostStore.lastGood ? { ...hostStore.lastGood } : undefined,
- usageStats: hostStore.usageStats ? { ...hostStore.usageStats } : undefined,
- });
- const tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-state-"));
- setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
- const tempAgentDir: string | undefined = path.join(
- tempStateDir,
- "agents",
- DEFAULT_AGENT_ID,
- "agent",
- );
- saveAuthProfileStore(sanitizedStore, tempAgentDir);
- const tempSessionAgentDir = path.join(tempStateDir, "agents", agentId, "agent");
- if (tempSessionAgentDir !== tempAgentDir) {
- saveAuthProfileStore(sanitizedStore, tempSessionAgentDir);
- }
- setTestEnvValue("OPENCLAW_AGENT_DIR", tempAgentDir);
-
- const workspaceDir = path.join(tempStateDir, "workspace-dev");
- await fs.mkdir(workspaceDir, { recursive: true });
- await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
- await fs.writeFile(
- path.join(workspaceDir, ".openclaw", "workspace-state.json"),
- `${JSON.stringify(
- {
- version: 1,
- setupCompletedAt: new Date().toISOString(),
- },
- null,
- 2,
- )}\n`,
- );
- await fs.rm(path.join(workspaceDir, "BOOTSTRAP.md"), { force: true });
- const nonceA = randomUUID();
- const nonceB = randomUUID();
- // Keep probe values out of the path: weak tool callers may echo the filename
- // instead of reading the file, turning nonceA into a false duplicate answer.
- const toolProbePath = path.join(workspaceDir, ".openclaw-live-tool-probe.txt");
- await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
-
- const agentDir = resolveDefaultAgentDir(params.cfg);
- const sanitizedCfg: OpenClawConfig = {
- ...params.cfg,
- auth: await sanitizeAuthConfig({ cfg: params.cfg, agentDir }),
- };
- const nextCfg = buildLiveGatewayConfig({
- cfg: sanitizedCfg,
- candidates: params.candidates,
- liveAgentDir: tempSessionAgentDir,
- liveAgentWorkspaceDir: workspaceDir,
- providerOverrides: params.providerOverrides,
- });
- const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-"));
- const tempConfigPath = path.join(tempDir, "openclaw.json");
- await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`);
- setTestEnvValue("OPENCLAW_CONFIG_PATH", tempConfigPath);
-
- const liveProviders = nextCfg.models?.providers;
- if (liveProviders && Object.keys(liveProviders).length > 0) {
- const modelsPath = path.join(tempAgentDir, "models.json");
- await fs.mkdir(tempAgentDir, { recursive: true });
- await fs.writeFile(modelsPath, `${JSON.stringify({ providers: liveProviders }, null, 2)}\n`);
- }
-
- // Keep the broad live Docker suite on the impl entrypoint. The lazy public
- // boundary (`./server.js`) is covered elsewhere, but under Vitest's live Docker
- // worker this path can trip a Node module-status loader bug during startup.
+ let runtimeEnv: ReturnType | undefined;
+ let cleanupTempStateDir: string | undefined;
+ let cleanupTempAgentDir: string | undefined;
+ let cleanupToolProbePath: string | undefined;
+ let cleanupTempDir: string | undefined;
+ let ultraWireCapture: OpenAIUltraWireCapture | undefined;
let server: Awaited> | undefined;
let client: GatewayClient | undefined;
+
try {
+ clearRuntimeConfigSnapshot();
+ runtimeEnv = enterProductionEnvForLiveRun();
+
+ process.env.OPENCLAW_SKIP_CHANNELS = "1";
+ process.env.OPENCLAW_SKIP_GMAIL_WATCHER = "1";
+ process.env.OPENCLAW_SKIP_CRON = "1";
+ process.env.OPENCLAW_SKIP_CANVAS_HOST = "1";
+ if (QUIET_LIVE_LOGS) {
+ process.env.OPENCLAW_DISABLE_BONJOUR = "1";
+ process.env.OPENCLAW_LOG_LEVEL = "silent";
+ }
+
+ const token = `test-${randomUUID()}`;
+ process.env.OPENCLAW_GATEWAY_TOKEN = token;
+ const agentId = GATEWAY_LIVE_AGENT_ID;
+
+ const hostAgentDir = resolveDefaultAgentDir(getRuntimeConfig());
+ const hostStore = ensureAuthProfileStore(hostAgentDir, {
+ allowKeychainPrompt: false,
+ });
+ const sanitizedStore = sanitizeAuthProfileStoreForLiveGateway({
+ version: hostStore.version,
+ profiles: { ...hostStore.profiles },
+ // Keep selection state so the gateway picks the same known-good profiles
+ // as the host (important when some profiles are rate-limited/disabled).
+ order: hostStore.order ? { ...hostStore.order } : undefined,
+ lastGood: hostStore.lastGood ? { ...hostStore.lastGood } : undefined,
+ usageStats: hostStore.usageStats ? { ...hostStore.usageStats } : undefined,
+ });
+ const tempStateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-state-"));
+ cleanupTempStateDir = tempStateDir;
+ setTestEnvValue("OPENCLAW_STATE_DIR", tempStateDir);
+ const tempAgentDir: string | undefined = path.join(
+ tempStateDir,
+ "agents",
+ DEFAULT_AGENT_ID,
+ "agent",
+ );
+ cleanupTempAgentDir = tempAgentDir;
+ saveAuthProfileStore(sanitizedStore, tempAgentDir);
+ const tempSessionAgentDir = path.join(tempStateDir, "agents", agentId, "agent");
+ if (tempSessionAgentDir !== tempAgentDir) {
+ saveAuthProfileStore(sanitizedStore, tempSessionAgentDir);
+ }
+ setTestEnvValue("OPENCLAW_AGENT_DIR", tempAgentDir);
+
+ const workspaceDir = path.join(tempStateDir, "workspace-dev");
+ await fs.mkdir(workspaceDir, { recursive: true });
+ await fs.mkdir(path.join(workspaceDir, ".openclaw"), { recursive: true });
+ await fs.writeFile(
+ path.join(workspaceDir, ".openclaw", "workspace-state.json"),
+ `${JSON.stringify(
+ {
+ version: 1,
+ setupCompletedAt: new Date().toISOString(),
+ },
+ null,
+ 2,
+ )}\n`,
+ );
+ await fs.rm(path.join(workspaceDir, "BOOTSTRAP.md"), { force: true });
+ const nonceA = randomUUID();
+ const nonceB = randomUUID();
+ // Keep probe values out of the path: weak tool callers may echo the filename
+ // instead of reading the file, turning nonceA into a false duplicate answer.
+ const toolProbePath = path.join(workspaceDir, ".openclaw-live-tool-probe.txt");
+ cleanupToolProbePath = toolProbePath;
+ await fs.writeFile(toolProbePath, `nonceA=${nonceA}\nnonceB=${nonceB}\n`);
+
+ const agentDir = resolveDefaultAgentDir(params.cfg);
+ const sanitizedCfg: OpenClawConfig = {
+ ...params.cfg,
+ auth: await sanitizeAuthConfig({ cfg: params.cfg, agentDir }),
+ };
+ let providerOverrides = params.providerOverrides;
+ if (ultraCandidates.length > 0) {
+ if (!ultraUpstreamBaseUrl) {
+ throw new Error("Ultra wire capture requires an explicit OpenAI base URL");
+ }
+ ultraWireCapture = await startOpenAIUltraWireCapture(ultraUpstreamBaseUrl);
+ providerOverrides = {
+ ...params.providerOverrides,
+ openai: buildOpenAIUltraWireProviderOverride({
+ baseUrl: ultraWireCapture.baseUrl,
+ candidates: ultraCandidates,
+ cfg: sanitizedCfg,
+ }),
+ };
+ }
+ const nextCfg = buildLiveGatewayConfig({
+ cfg: sanitizedCfg,
+ candidates: params.candidates,
+ liveAgentDir: tempSessionAgentDir,
+ liveAgentWorkspaceDir: workspaceDir,
+ providerOverrides,
+ });
+ const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-live-"));
+ cleanupTempDir = tempDir;
+ const tempConfigPath = path.join(tempDir, "openclaw.json");
+ await fs.writeFile(tempConfigPath, `${JSON.stringify(nextCfg, null, 2)}\n`);
+ setTestEnvValue("OPENCLAW_CONFIG_PATH", tempConfigPath);
+
+ const liveProviders = nextCfg.models?.providers;
+ if (liveProviders && Object.keys(liveProviders).length > 0) {
+ const modelsPath = path.join(tempAgentDir, "models.json");
+ await fs.mkdir(tempAgentDir, { recursive: true });
+ await fs.writeFile(modelsPath, `${JSON.stringify({ providers: liveProviders }, null, 2)}\n`);
+ }
+
+ // Keep the broad live Docker suite on the impl entrypoint. The lazy public
+ // boundary (`./server.js`) is covered elsewhere, but under Vitest's live Docker
+ // worker this path can trip a Node module-status loader bug during startup.
try {
const port = await withGatewayLiveProbeTimeout(
getFreeGatewayPort(),
@@ -3971,6 +4608,9 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
for (const [index, model] of params.candidates.entries()) {
const modelKey = `${model.provider}/${model.id}`;
const progressLabel = `[${params.label}] ${index + 1}/${total} ${modelKey}`;
+ const strictUltraProof = isOpenAIGpt56UltraTarget(model, params.thinkingLevel);
+ const skippedBeforeModel = skippedCount;
+ const wireObservationStart = ultraWireCapture?.observations.length ?? 0;
const thinkingLevel = resolveGatewayLiveModelThinkingLevel({
cfg: params.cfg,
model,
@@ -4007,9 +4647,19 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
client.request("sessions.patch", {
key: sessionKey,
model: modelKey,
+ ...(strictUltraProof ? { thinkingLevel } : {}),
}),
`${progressLabel}: sessions-patch`,
);
+ if (strictUltraProof) {
+ await assertGatewayLiveSessionSelection({
+ client,
+ sessionKey,
+ expectedProvider: normalizeProviderId(model.provider),
+ expectedModelId: model.id,
+ expectedThinkingLevel: thinkingLevel,
+ });
+ }
logProgress(`${progressLabel}: prompt`);
let text = await requestGatewayAgentText({
@@ -4104,6 +4754,30 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
throw new Error(`missing required keywords: ${text}`);
}
+ if (strictUltraProof) {
+ await assertGatewayLiveSessionSelection({
+ client,
+ sessionKey,
+ expectedProvider: normalizeProviderId(model.provider),
+ expectedModelId: model.id,
+ expectedThinkingLevel: thinkingLevel,
+ });
+ await assertGatewayLiveTranscriptModelIdentity({
+ sessionKey,
+ expectedProvider: normalizeProviderId(model.provider),
+ expectedModelId: model.id,
+ });
+ logProgress(`${progressLabel}: ultra sessions_spawn handoff`);
+ await verifyGatewayUltraSubagentHandoff({
+ client,
+ sessionKey,
+ modelKey,
+ thinkingLevel,
+ expectedProvider: normalizeProviderId(model.provider),
+ expectedModelId: model.id,
+ });
+ }
+
// Real tool invocation: force the agent to Read a local file and echo a nonce.
logProgress(`${progressLabel}: tool-read`);
const runIdTool = randomUUID();
@@ -4386,6 +5060,20 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
thinkingLevel,
});
}
+ if (strictUltraProof) {
+ if (!ultraWireCapture) {
+ throw new Error(`${modelKey}: missing Ultra wire capture`);
+ }
+ // Check every request made by the passing model lane, including
+ // child and tool-followup turns, so later paths cannot downgrade.
+ const capturedRequestCount = assertOpenAIUltraWireEffort({
+ expectedModel: model.id,
+ observations: ultraWireCapture.observations.slice(wireObservationStart),
+ });
+ logProgress(
+ `${progressLabel}: ultra wire effort=max captured_requests=${capturedRequestCount}`,
+ );
+ }
return "done";
})(),
`${progressLabel}: model`,
@@ -4586,6 +5274,12 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
break;
}
}
+ if (strictUltraProof && skippedCount > skippedBeforeModel) {
+ failures.push({
+ model: modelKey,
+ error: "strict GPT-5.6 Ultra proof was skipped; inspect the preceding live log",
+ });
+ }
}
if (failures.length > 0) {
@@ -4603,32 +5297,62 @@ async function runGatewayModelSuite(params: GatewayModelSuiteParams) {
});
} finally {
clearRuntimeConfigSnapshot();
- restoreProductionEnvForLiveRun(runtimeEnv);
client?.stop();
- if (server) {
- await server.close({ reason: "live test complete" });
+ try {
+ try {
+ if (server) {
+ await server.close({ reason: "live test complete" });
+ }
+ } finally {
+ await ultraWireCapture?.close();
+ }
+ } finally {
+ try {
+ if (cleanupToolProbePath) {
+ await fs.rm(cleanupToolProbePath, { force: true });
+ }
+ // Give the filesystem a short retry window while agent/runtime teardown
+ // releases handles inside these temporary live-test directories.
+ if (cleanupTempDir) {
+ await fs.rm(cleanupTempDir, {
+ recursive: true,
+ force: true,
+ maxRetries: 5,
+ retryDelay: 50,
+ });
+ }
+ if (cleanupTempAgentDir) {
+ await fs.rm(cleanupTempAgentDir, {
+ recursive: true,
+ force: true,
+ maxRetries: 5,
+ retryDelay: 50,
+ });
+ }
+ if (cleanupTempStateDir) {
+ await fs.rm(cleanupTempStateDir, {
+ recursive: true,
+ force: true,
+ maxRetries: 5,
+ retryDelay: 50,
+ });
+ }
+ } finally {
+ if (runtimeEnv) {
+ restoreProductionEnvForLiveRun(runtimeEnv);
+ }
+ restoreOptionalEnv("OPENCLAW_CONFIG_PATH", previous.configPath);
+ restoreOptionalEnv("OPENCLAW_GATEWAY_TOKEN", previous.token);
+ restoreOptionalEnv("OPENCLAW_SKIP_CHANNELS", previous.skipChannels);
+ restoreOptionalEnv("OPENCLAW_SKIP_GMAIL_WATCHER", previous.skipGmail);
+ restoreOptionalEnv("OPENCLAW_SKIP_CRON", previous.skipCron);
+ restoreOptionalEnv("OPENCLAW_SKIP_CANVAS_HOST", previous.skipCanvas);
+ restoreOptionalEnv("OPENCLAW_DISABLE_BONJOUR", previous.disableBonjour);
+ restoreOptionalEnv("OPENCLAW_LOG_LEVEL", previous.logLevel);
+ restoreOptionalEnv("OPENCLAW_AGENT_DIR", previous.agentDir);
+ restoreOptionalEnv("OPENCLAW_STATE_DIR", previous.stateDir);
+ }
}
- await fs.rm(toolProbePath, { force: true });
- // Give the filesystem a short retry window while agent/runtime teardown
- // releases handles inside these temporary live-test directories.
- await fs.rm(tempDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
- if (tempAgentDir) {
- await fs.rm(tempAgentDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
- }
- if (tempStateDir) {
- await fs.rm(tempStateDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
- }
-
- restoreOptionalEnv("OPENCLAW_CONFIG_PATH", previous.configPath);
- restoreOptionalEnv("OPENCLAW_GATEWAY_TOKEN", previous.token);
- restoreOptionalEnv("OPENCLAW_SKIP_CHANNELS", previous.skipChannels);
- restoreOptionalEnv("OPENCLAW_SKIP_GMAIL_WATCHER", previous.skipGmail);
- restoreOptionalEnv("OPENCLAW_SKIP_CRON", previous.skipCron);
- restoreOptionalEnv("OPENCLAW_SKIP_CANVAS_HOST", previous.skipCanvas);
- restoreOptionalEnv("OPENCLAW_DISABLE_BONJOUR", previous.disableBonjour);
- restoreOptionalEnv("OPENCLAW_LOG_LEVEL", previous.logLevel);
- restoreOptionalEnv("OPENCLAW_AGENT_DIR", previous.agentDir);
- restoreOptionalEnv("OPENCLAW_STATE_DIR", previous.stateDir);
}
}
@@ -4657,7 +5381,7 @@ describeLive("gateway live (dev agent, profile keys)", () => {
);
const agentDir = modelsJsonResult.agentDir;
- const rawModels = process.env.OPENCLAW_LIVE_GATEWAY_MODELS?.trim();
+ const rawModels = await resolveGatewayLiveRequestedModels();
const useModern = !rawModels || rawModels === "modern" || rawModels === "all";
const useSmall = rawModels === "small";
const useExplicit = Boolean(rawModels) && !useModern && !useSmall;
diff --git a/src/gateway/server-methods/sessions.ts b/src/gateway/server-methods/sessions.ts
index 71f66560af4f..af57c14afc78 100644
--- a/src/gateway/server-methods/sessions.ts
+++ b/src/gateway/server-methods/sessions.ts
@@ -32,16 +32,14 @@ import {
validateSessionsResolveParams,
validateSessionsSendParams,
} from "../../../packages/gateway-protocol/src/index.js";
-import { readAcpSessionMeta } from "../../acp/runtime/session-meta.js";
-import { resolveModelAgentRuntimeMetadata } from "../../agents/agent-runtime-metadata.js";
import { resolveAgentWorkspaceDir, resolveDefaultAgentId } from "../../agents/agent-scope.js";
-import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js";
import {
abortEmbeddedAgentRun,
isEmbeddedAgentRunActive,
waitForEmbeddedAgentRunEnd,
} from "../../agents/embedded-agent-runner/runs.js";
import { compactEmbeddedAgentSession } from "../../agents/embedded-agent.js";
+import { resolveIngressWorkspaceOverrideForSessionRun } from "../../agents/spawned-context.js";
import { insideGitCheckout } from "../../agents/worktrees/git.js";
import { managedWorktrees } from "../../agents/worktrees/service.js";
import { clearSessionQueues } from "../../auto-reply/reply/queue/cleanup.js";
@@ -116,6 +114,7 @@ import {
resolveFreshestSessionEntryFromStoreKeys,
resolveGatewaySessionStoreTarget,
resolveGatewaySessionStoreTargetWithStore,
+ resolveGatewaySessionThinkingProjection,
resolveSessionDisplayModelIdentityRef,
resolveSessionModelRef,
resolveSessionTranscriptCandidates,
@@ -2019,6 +2018,12 @@ export const sessionsHandlers: GatewayRequestHandlers = {
);
return;
}
+ let patchModelCatalog: Awaited> | undefined;
+ const loadPatchModelCatalog = async () => {
+ const catalog = await context.loadGatewayModelCatalog();
+ patchModelCatalog = catalog;
+ return catalog;
+ };
const applyPatch = async () => {
const currentLifecycleEntry = loadSessionEntry(key, { agentId: requestedAgentId }).entry;
// A reset queued ahead of archive can rotate the row before this mutation starts.
@@ -2093,7 +2098,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
storeKey: primaryKey,
agentId: requestedAgentId,
patch: p,
- loadGatewayModelCatalog: context.loadGatewayModelCatalog,
+ loadGatewayModelCatalog: loadPatchModelCatalog,
}),
});
};
@@ -2130,16 +2135,22 @@ export const sessionsHandlers: GatewayRequestHandlers = {
provider: resolved.provider,
model: resolved.model,
});
- const acpMeta = readAcpSessionMeta({ sessionKey: target.canonicalKey ?? key });
- const agentRuntime = resolveModelAgentRuntimeMetadata({
+ const thinkingProjection = resolveGatewaySessionThinkingProjection({
cfg,
agentId,
- provider: resolvedDisplayModel.provider,
- model: resolvedDisplayModel.model,
+ provider: resolvedDisplayModel.provider ?? resolved.provider,
+ model: resolvedDisplayModel.model ?? resolved.model,
sessionKey: target.canonicalKey ?? key,
- acpRuntime: acpMeta != null,
- acpBackend: acpMeta?.backend,
+ entry: applied.entry,
+ modelCatalog: patchModelCatalog,
});
+ const resolvedThinkingMetadata =
+ patchModelCatalog === undefined
+ ? {}
+ : {
+ thinkingLevel: thinkingProjection.effectiveThinkingLevel,
+ thinkingLevels: thinkingProjection.thinkingLevels,
+ };
const result: SessionsPatchResult = {
ok: true,
path: storePath,
@@ -2148,7 +2159,8 @@ export const sessionsHandlers: GatewayRequestHandlers = {
resolved: {
modelProvider: resolvedDisplayModel.provider,
model: resolvedDisplayModel.model,
- agentRuntime,
+ agentRuntime: thinkingProjection.agentRuntime,
+ ...resolvedThinkingMetadata,
},
};
respond(true, result, undefined);
@@ -2858,8 +2870,7 @@ export const sessionsHandlers: GatewayRequestHandlers = {
spawnedBy: latestEntry.spawnedBy,
workspaceDir: latestEntry.spawnedWorkspaceDir,
cwd: latestEntry.spawnedCwd,
- }) ??
- resolveAgentWorkspaceDir(cfg, target.agentId);
+ }) ?? resolveAgentWorkspaceDir(cfg, target.agentId);
const operationId = randomUUID();
emitSessionOperation(context, {
operationId,
diff --git a/src/gateway/server-session-events.test.ts b/src/gateway/server-session-events.test.ts
index 6c3efc4ccbce..8faeb060bbf8 100644
--- a/src/gateway/server-session-events.test.ts
+++ b/src/gateway/server-session-events.test.ts
@@ -7,6 +7,11 @@ const sessionRow = vi.hoisted(() => ({
sessionId: "sess-main",
status: "done",
updatedAt: 1,
+ thinkingLevel: "ultra" as string | undefined,
+ thinkingLevels: [{ id: "ultra", label: "ultra" }],
+ thinkingOptions: ["ultra"],
+ thinkingDefault: "medium",
+ agentRuntime: { id: "openclaw", source: "model" },
}));
const isEmbeddedAgentRunActiveMock = vi.hoisted(() => vi.fn());
@@ -74,6 +79,7 @@ describe("createTranscriptUpdateBroadcastHandler", () => {
beforeEach(() => {
vi.clearAllMocks();
isEmbeddedAgentRunActiveMock.mockReturnValue(false);
+ sessionRow.thinkingLevel = "ultra";
});
it("keeps transcript snapshots active while plugin finalization delays the terminal event", async () => {
@@ -87,6 +93,31 @@ describe("createTranscriptUpdateBroadcastHandler", () => {
});
});
+ it("keeps stable thinking state without catalog-derived picker metadata", async () => {
+ const payload = await emitAssistantTranscriptUpdate(false);
+
+ expect(payload).toMatchObject({
+ session: {
+ thinkingLevel: "ultra",
+ agentRuntime: { id: "openclaw" },
+ },
+ });
+ expect(payload).not.toHaveProperty("thinkingLevels");
+ expect(payload).not.toHaveProperty("thinkingOptions");
+ expect(payload).not.toHaveProperty("thinkingDefault");
+ expect(payload).not.toHaveProperty("session.thinkingLevels");
+ expect(payload).not.toHaveProperty("session.thinkingOptions");
+ expect(payload).not.toHaveProperty("session.thinkingDefault");
+ });
+
+ it("emits an explicit null when the thinking override is cleared", async () => {
+ sessionRow.thinkingLevel = undefined;
+
+ await expect(emitAssistantTranscriptUpdate(false)).resolves.toMatchObject({
+ session: { thinkingLevel: null },
+ });
+ });
+
it("keeps stale-run recovery when terminal lifecycle has cleared active projection", async () => {
await expect(emitAssistantTranscriptUpdate(false)).resolves.toMatchObject({
sessionKey: "agent:main:main",
diff --git a/src/gateway/server-session-events.ts b/src/gateway/server-session-events.ts
index c6fe8511a4a9..4cff76b255c6 100644
--- a/src/gateway/server-session-events.ts
+++ b/src/gateway/server-session-events.ts
@@ -15,7 +15,10 @@ import type {
SessionMessageSubscriberRegistry,
} from "./server-chat.js";
import { resolveVisibleActiveSessionRunState } from "./server-methods/session-active-runs.js";
-import { buildGatewaySessionEventFields } from "./session-event-payload.js";
+import {
+ buildGatewaySessionEventFields,
+ buildGatewaySessionEventRow,
+} from "./session-event-payload.js";
import { resolveSessionKeyForTranscriptFile } from "./session-transcript-key.js";
import {
attachOpenClawTranscriptMeta,
@@ -81,7 +84,13 @@ function buildGatewaySessionSnapshot(params: {
if (!sessionRow) {
return {};
}
- const session = params.includeSession ? { ...sessionRow } : undefined;
+ // Nested snapshots are the UI merge source, so preserve explicit clear semantics there too.
+ const session = params.includeSession
+ ? {
+ ...buildGatewaySessionEventRow(sessionRow),
+ thinkingLevel: sessionRow.thinkingLevel ?? null,
+ }
+ : undefined;
if (session && sessionRow.key === "global" && !params.agentId) {
// The unscoped global row hides goal state to avoid presenting one agent's
// scoped goal as the global/default session goal.
diff --git a/src/gateway/server-startup-log.test.ts b/src/gateway/server-startup-log.test.ts
index bc669811664b..575e326fe6d1 100644
--- a/src/gateway/server-startup-log.test.ts
+++ b/src/gateway/server-startup-log.test.ts
@@ -296,6 +296,17 @@ describe("gateway startup log", () => {
expect(modelMocks.resolveThinkingDefault).not.toHaveBeenCalled();
});
+ it("preserves explicit Ultra in startup model details", () => {
+ expect(
+ formatAgentModelStartupDetails({
+ cfg: { agents: { defaults: { thinkingDefault: "ultra" } } },
+ provider: "openai",
+ model: "gpt-5.6-sol",
+ }),
+ ).toBe("thinking=ultra, fast=off");
+ expect(modelMocks.resolveThinkingDefault).not.toHaveBeenCalled();
+ });
+
it("shows thinking off for configured provider models with reasoning disabled", () => {
expect(
formatAgentModelStartupDetails({
diff --git a/src/gateway/server-startup-log.ts b/src/gateway/server-startup-log.ts
index ecd32614b23f..6865a51cf544 100644
--- a/src/gateway/server-startup-log.ts
+++ b/src/gateway/server-startup-log.ts
@@ -25,7 +25,8 @@ type StartupThinkLevel =
| "high"
| "xhigh"
| "adaptive"
- | "max";
+ | "max"
+ | "ultra";
/** Emit startup summary lines after Gateway bind and plugin loading complete. */
export async function logGatewayStartup(params: {
@@ -95,7 +96,8 @@ function normalizeStartupThinkLevel(value: unknown): StartupThinkLevel | undefin
value === "high" ||
value === "xhigh" ||
value === "adaptive" ||
- value === "max"
+ value === "max" ||
+ value === "ultra"
? value
: undefined;
}
diff --git a/src/gateway/server.sessions.list-changed.test.ts b/src/gateway/server.sessions.list-changed.test.ts
index f944d9aaae73..7038e78ccf99 100644
--- a/src/gateway/server.sessions.list-changed.test.ts
+++ b/src/gateway/server.sessions.list-changed.test.ts
@@ -446,6 +446,116 @@ test("sessions.list uses the gateway model catalog for effective thinking defaul
});
});
+test.each(["gpt-5.6-sol", "gpt-5.6-terra"])(
+ "sessions.patch returns authoritative native Codex Ultra metadata for %s",
+ async (model) => {
+ const registry = createEmptyPluginRegistry();
+ registry.providers.push({
+ pluginId: "openai",
+ source: "test",
+ provider: {
+ id: "openai",
+ label: "OpenAI",
+ auth: [],
+ resolveThinkingProfile: ({ compat }) => ({
+ levels: [
+ { id: "off" },
+ { id: "high" },
+ { id: "max" },
+ ...(compat?.supportedReasoningEfforts?.includes("ultra")
+ ? [{ id: "ultra" as const }]
+ : []),
+ ],
+ defaultLevel: "high",
+ }),
+ },
+ });
+ setActivePluginRegistry(registry);
+ testState.agentConfig = {
+ model: { primary: `openai/${model}` },
+ models: {
+ [`openai/${model}`]: { agentRuntime: { id: "codex" } },
+ },
+ };
+ await writeMainSessionStore({ modelProvider: "openai", model });
+ const loadGatewayModelCatalog = vi.fn(async () => [
+ {
+ provider: "openai",
+ id: model,
+ name: model,
+ reasoning: true,
+ compat: {
+ supportedReasoningEfforts: ["low", "medium", "high", "xhigh", "max", "ultra"],
+ },
+ },
+ ]);
+
+ const result = await invokeSessionMutation({
+ method: "sessions.patch",
+ params: { key: "main", thinkingLevel: "ultra" },
+ context: { loadGatewayModelCatalog },
+ });
+
+ const resolved = requireRecord(result.responsePayload.resolved, "resolved patch metadata");
+ expectFields(resolved, {
+ modelProvider: "openai",
+ model,
+ thinkingLevel: "ultra",
+ });
+ expect(requireRecord(resolved.agentRuntime, "resolved agent runtime").id).toBe("codex");
+ expect(
+ requireArray(resolved.thinkingLevels, "resolved thinking levels").map(
+ (level) => requireRecord(level, "thinking level").id,
+ ),
+ ).toContain("ultra");
+ expect(loadGatewayModelCatalog).toHaveBeenCalledTimes(1);
+
+ const event = expectChangedBroadcast(result.broadcastToConnIds, {
+ sessionKey: "agent:main:main",
+ reason: "patch",
+ thinkingLevel: "ultra",
+ });
+ expect(event).not.toHaveProperty("thinkingLevels");
+ expect(event).not.toHaveProperty("thinkingOptions");
+ expect(event).not.toHaveProperty("thinkingDefault");
+ },
+);
+
+test("sessions.patch omits thinking metadata when an unrelated patch skips the catalog", async () => {
+ testState.agentConfig = {
+ model: { primary: "synthetic/plain" },
+ };
+ await writeMainSessionStore({
+ modelProvider: "synthetic",
+ model: "plain",
+ thinkingLevel: "max",
+ });
+ const loadGatewayModelCatalog = vi.fn(async () => [
+ {
+ provider: "synthetic",
+ id: "plain",
+ name: "plain",
+ reasoning: false,
+ },
+ ]);
+
+ const result = await invokeSessionMutation({
+ method: "sessions.patch",
+ params: { key: "main", label: "Renamed" },
+ context: { loadGatewayModelCatalog },
+ });
+
+ const resolved = requireRecord(result.responsePayload.resolved, "resolved patch metadata");
+ expect(resolved).not.toHaveProperty("thinkingLevel");
+ expect(resolved).not.toHaveProperty("thinkingLevels");
+ expect(loadGatewayModelCatalog).not.toHaveBeenCalled();
+ expectChangedBroadcast(result.broadcastToConnIds, {
+ sessionKey: "agent:main:main",
+ reason: "patch",
+ thinkingLevel: "max",
+ });
+});
+
test("sessions.list exposes effective fast auto defaults from the selected model", async () => {
testState.agentConfig = {
model: { primary: "openai/gpt-5.5" },
diff --git a/src/gateway/session-event-payload.ts b/src/gateway/session-event-payload.ts
index c145023d9482..ebe1884c97cb 100644
--- a/src/gateway/session-event-payload.ts
+++ b/src/gateway/session-event-payload.ts
@@ -1,5 +1,18 @@
import type { GatewaySessionRow } from "./session-utils.js";
+/**
+ * Project a catalog-less session row for websocket merge events.
+ * Picker metadata comes from catalog-backed list/patch responses; emitting a
+ * locally reconstructed subset here would replace richer client state.
+ */
+export function buildGatewaySessionEventRow(sessionRow: GatewaySessionRow): GatewaySessionRow {
+ const session = { ...sessionRow };
+ delete session.thinkingLevels;
+ delete session.thinkingOptions;
+ delete session.thinkingDefault;
+ return session;
+}
+
export function buildGatewaySessionEventFields(params: {
sessionRow: GatewaySessionRow;
agentId?: string;
@@ -42,7 +55,8 @@ export function buildGatewaySessionEventFields(params: {
deliveryContext: sessionRow.deliveryContext,
parentSessionKey: params.parentSessionKey ?? sessionRow.parentSessionKey,
childSessions: sessionRow.childSessions,
- thinkingLevel: sessionRow.thinkingLevel,
+ // Explicit null lets subscribed clients clear an override during merge-reconcile.
+ thinkingLevel: sessionRow.thinkingLevel ?? null,
fastMode: sessionRow.fastMode,
verboseLevel: sessionRow.verboseLevel,
reasoningLevel: sessionRow.reasoningLevel,
@@ -65,6 +79,7 @@ export function buildGatewaySessionEventFields(params: {
effectiveResponseUsage: sessionRow.effectiveResponseUsage,
modelProvider: sessionRow.modelProvider,
model: sessionRow.model,
+ agentRuntime: sessionRow.agentRuntime,
status: sessionRow.status,
...(params.hasActiveRun === undefined ? {} : { hasActiveRun: params.hasActiveRun }),
...(params.activeRunIds === undefined ? {} : { activeRunIds: params.activeRunIds }),
diff --git a/src/gateway/session-utils.test.ts b/src/gateway/session-utils.test.ts
index 5febe43c6a4b..0768def229d6 100644
--- a/src/gateway/session-utils.test.ts
+++ b/src/gateway/session-utils.test.ts
@@ -647,6 +647,78 @@ describe("gateway session utils", () => {
);
});
+ test("preserves persisted Ultra while projecting picker levels without a catalog", () => {
+ providerArtifactMocks.resolveBundledProviderPolicySurface.mockReturnValue({
+ resolveThinkingProfile: ({ modelId, agentRuntime }) => ({
+ levels: [
+ { id: "off" },
+ { id: "high" },
+ { id: "xhigh" },
+ { id: "max" },
+ ...(modelId.startsWith("gpt-5.6") &&
+ (agentRuntime === "openclaw" || !modelId.startsWith("gpt-5.6-luna"))
+ ? [{ id: "ultra" as const }]
+ : []),
+ ],
+ }),
+ });
+ const cfg = {
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.6-luna" },
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ } as OpenClawConfig;
+ const defaults = getSessionDefaults(cfg);
+ const row = (entry: SessionEntry) =>
+ buildGatewaySessionRow({
+ cfg,
+ storePath: "",
+ store: {},
+ key: "agent:main:main",
+ entry,
+ });
+
+ const codex = row({ sessionId: "codex", thinkingLevel: "ultra" } as SessionEntry);
+ const openClawOverride = row({
+ sessionId: "openclaw",
+ thinkingLevel: "ultra",
+ agentRuntimeOverride: "openclaw",
+ } as SessionEntry);
+ const legacyObservedOpenClaw = row({
+ sessionId: "legacy-observed-openclaw",
+ thinkingLevel: "ultra",
+ agentHarnessId: "openclaw",
+ } as SessionEntry);
+
+ expect(defaults.agentRuntime?.id).toBe("codex");
+ expect(codex.thinkingLevel).toBe("ultra");
+ expect(codex.thinkingLevels?.map((level) => level.id)).not.toContain("ultra");
+ expect(openClawOverride.thinkingLevel).toBe("ultra");
+ expect(openClawOverride.agentRuntime?.id).toBe("openclaw");
+ expect(legacyObservedOpenClaw.thinkingLevel).toBe("ultra");
+ expect(legacyObservedOpenClaw.agentRuntime?.id).toBe("codex");
+ expect(legacyObservedOpenClaw.thinkingLevels?.map((level) => level.id)).not.toContain("ultra");
+ });
+
+ test.each(["xhigh", "max"] as const)(
+ "preserves catalog-less persisted %s in session change projections",
+ (thinkingLevel) => {
+ const row = buildGatewaySessionRow({
+ cfg: createModelDefaultsConfig({ primary: "custom/reasoner" }),
+ storePath: "",
+ store: {},
+ key: "agent:main:main",
+ entry: { sessionId: thinkingLevel, thinkingLevel } as SessionEntry,
+ });
+
+ expect(row.thinkingLevel).toBe(thinkingLevel);
+ },
+ );
+
test("session defaults use configured thinking default", () => {
const defaults = getSessionDefaults({
agents: {
diff --git a/src/gateway/session-utils.ts b/src/gateway/session-utils.ts
index 65d1c2c74adb..a5013a8a3bf3 100644
--- a/src/gateway/session-utils.ts
+++ b/src/gateway/session-utils.ts
@@ -43,6 +43,7 @@ import {
resolveSessionModelIdentityRef,
resolveSessionModelRef,
} from "../agents/session-model-ref.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../agents/session-runtime-compat.js";
import {
buildSubagentRunReadIndex,
countActiveDescendantRuns,
@@ -57,8 +58,17 @@ import {
RECENT_ENDED_SUBAGENT_CHILD_SESSION_MS,
shouldKeepSubagentRunChildLink,
} from "../agents/subagent-run-liveness.js";
+import {
+ concretizeAgentRuntime,
+ resolveEffectiveAgentRuntime,
+} from "../agents/thinking-runtime.js";
import { insideGitCheckout } from "../agents/worktrees/git.js";
-import { listThinkingLevelOptions, resolveEffectiveResponseUsage } from "../auto-reply/thinking.js";
+import {
+ listThinkingLevelOptions,
+ normalizeThinkLevel,
+ resolveEffectiveResponseUsage,
+ resolveSupportedThinkingLevel,
+} from "../auto-reply/thinking.js";
import { getRuntimeConfig } from "../config/io.js";
import { resolveAgentModelFallbackValues } from "../config/model-input.js";
import {
@@ -740,6 +750,7 @@ function resolveSessionRowThinkingMetadata(params: {
agentId: string;
provider: string;
model: string;
+ agentRuntime: string;
modelCatalog?: ModelCatalogEntry[];
rowContext?: SessionListRowContext;
}): {
@@ -748,17 +759,23 @@ function resolveSessionRowThinkingMetadata(params: {
} {
if (!params.rowContext) {
return {
- levels: listThinkingLevelOptions(params.provider, params.model, params.modelCatalog),
+ levels: listThinkingLevelOptions(
+ params.provider,
+ params.model,
+ params.modelCatalog,
+ params.agentRuntime,
+ ),
defaultLevel: resolveGatewaySessionThinkingDefault({
cfg: params.cfg,
provider: params.provider,
model: params.model,
agentId: params.agentId,
modelCatalog: params.modelCatalog,
+ agentRuntime: params.agentRuntime,
}),
};
}
- const key = `${normalizeAgentId(params.agentId)}\0${createSessionRowModelCacheKey(
+ const key = `${normalizeAgentId(params.agentId)}\0${params.agentRuntime}\0${createSessionRowModelCacheKey(
params.provider,
params.model,
)}`;
@@ -767,13 +784,19 @@ function resolveSessionRowThinkingMetadata(params: {
return cached;
}
const metadata = {
- levels: listThinkingLevelOptions(params.provider, params.model, params.modelCatalog),
+ levels: listThinkingLevelOptions(
+ params.provider,
+ params.model,
+ params.modelCatalog,
+ params.agentRuntime,
+ ),
defaultLevel: resolveGatewaySessionThinkingDefault({
cfg: params.cfg,
provider: params.provider,
model: params.model,
agentId: params.agentId,
modelCatalog: params.modelCatalog,
+ agentRuntime: params.agentRuntime,
}),
};
params.rowContext.thinkingMetadataByModelRef.set(key, metadata);
@@ -1227,10 +1250,27 @@ export function listAgentsForGateway(
const meta = configuredById.get(id);
const model = resolveGatewayAgentModel(cfg, id);
const resolvedModel = resolveDefaultModelForAgent({ cfg, agentId: id });
+ const sessionKey = resolveAgentMainSessionKey({ cfg, agentId: id });
+ const agentRuntime = resolveModelAgentRuntimeMetadata({
+ cfg,
+ agentId: id,
+ provider: resolvedModel.provider,
+ model: resolvedModel.model,
+ sessionKey,
+ acpRuntime: false,
+ });
+ const thinkingRuntime = resolveEffectiveAgentRuntime({
+ cfg,
+ provider: resolvedModel.provider,
+ modelId: resolvedModel.model,
+ agentId: id,
+ sessionKey,
+ });
const thinkingLevels = listThinkingLevelOptions(
resolvedModel.provider,
resolvedModel.model,
modelCatalog,
+ thinkingRuntime,
);
const workspace = resolveAgentWorkspaceDir(cfg, id);
// Must mirror the sessions.create worktree preflight: subdirectory workspaces inside a
@@ -1243,14 +1283,7 @@ export function listAgentsForGateway(
identity: meta?.identity,
workspace,
workspaceGit,
- agentRuntime: resolveModelAgentRuntimeMetadata({
- cfg,
- agentId: id,
- provider: resolvedModel.provider,
- model: resolvedModel.model,
- sessionKey: resolveAgentMainSessionKey({ cfg, agentId: id }),
- acpRuntime: false,
- }),
+ agentRuntime,
thinkingLevels,
thinkingOptions: thinkingLevels.map((level) => level.label),
thinkingDefault: resolveGatewaySessionThinkingDefault({
@@ -1259,6 +1292,7 @@ export function listAgentsForGateway(
model: resolvedModel.model,
agentId: id,
modelCatalog,
+ agentRuntime: thinkingRuntime,
}),
},
model ? { model } : {},
@@ -1509,25 +1543,146 @@ export function resolveGatewaySessionStoreTarget(params: {
export { loadCombinedSessionStoreForGateway } from "../config/sessions/combined-store-gateway.js";
+function resolveGatewaySessionThinkingLevel(params: {
+ provider: string;
+ model: string;
+ level: NonNullable>;
+ modelCatalog?: ModelCatalogEntry[];
+ agentRuntime: string;
+}) {
+ const catalogEntry = params.modelCatalog
+ ? findModelCatalogEntry(params.modelCatalog, {
+ provider: params.provider,
+ modelId: params.model,
+ })
+ : undefined;
+ // Lightweight sessions.changed projections intentionally omit the catalog.
+ // Runtime/model patches normalize persisted state with authoritative metadata;
+ // projections must not reinterpret an already-validated level without it.
+ if (!catalogEntry) {
+ return params.level;
+ }
+ return resolveSupportedThinkingLevel({
+ provider: params.provider,
+ model: params.model,
+ level: params.level,
+ catalog: params.modelCatalog,
+ agentRuntime: params.agentRuntime,
+ });
+}
+
function resolveGatewaySessionThinkingDefault(params: {
cfg: OpenClawConfig;
provider: string;
model: string;
agentId?: string;
modelCatalog?: ModelCatalogEntry[];
+ agentRuntime: string;
}) {
const agentThinkingDefault = params.agentId
? resolveAgentConfig(params.cfg, params.agentId)?.thinkingDefault
: undefined;
- return (
+ const defaultLevel =
agentThinkingDefault ??
resolveThinkingDefault({
cfg: params.cfg,
provider: params.provider,
model: params.model,
catalog: params.modelCatalog,
- })
- );
+ agentRuntime: params.agentRuntime,
+ });
+ return resolveGatewaySessionThinkingLevel({
+ provider: params.provider,
+ model: params.model,
+ level: defaultLevel,
+ modelCatalog: params.modelCatalog,
+ agentRuntime: params.agentRuntime,
+ });
+}
+
+type GatewaySessionThinkingProjectionParams = {
+ cfg: OpenClawConfig;
+ provider: string;
+ model: string;
+ agentId: string;
+ sessionKey: string;
+ entry?: SessionEntry;
+ modelCatalog?: ModelCatalogEntry[];
+ rowContext?: SessionListRowContext;
+};
+
+function resolveGatewaySessionThinkingProjectionInternal(
+ params: GatewaySessionThinkingProjectionParams,
+) {
+ const acpMeta = readAcpSessionMeta({ sessionKey: params.sessionKey });
+ const configuredAgentRuntime = resolveModelAgentRuntimeMetadata({
+ cfg: params.cfg,
+ agentId: params.agentId,
+ provider: params.provider,
+ model: params.model,
+ sessionKey: params.sessionKey,
+ acpRuntime: acpMeta != null,
+ acpBackend: acpMeta?.backend,
+ });
+ const persistedAgentRuntime = resolveSessionRuntimeOverrideForProvider({
+ provider: params.provider,
+ entry: params.entry,
+ cfg: params.cfg,
+ });
+ const agentRuntime =
+ acpMeta || !persistedAgentRuntime
+ ? configuredAgentRuntime
+ : { id: persistedAgentRuntime, source: "session-key" as const };
+ const thinkingRuntime = acpMeta
+ ? concretizeAgentRuntime(acpMeta.backend ?? agentRuntime.id)
+ : resolveEffectiveAgentRuntime({
+ cfg: params.cfg,
+ provider: params.provider,
+ modelId: params.model,
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ sessionEntry: params.entry,
+ });
+ const metadata = resolveSessionRowThinkingMetadata({
+ cfg: params.cfg,
+ agentId: params.agentId,
+ provider: params.provider,
+ model: params.model,
+ agentRuntime: thinkingRuntime,
+ modelCatalog: params.modelCatalog,
+ rowContext: params.rowContext,
+ });
+ const storedThinkingLevel = normalizeThinkLevel(params.entry?.thinkingLevel);
+ const thinkingLevel = storedThinkingLevel
+ ? resolveGatewaySessionThinkingLevel({
+ provider: params.provider,
+ model: params.model,
+ level: storedThinkingLevel,
+ modelCatalog: params.modelCatalog,
+ agentRuntime: thinkingRuntime,
+ })
+ : undefined;
+ return {
+ agentRuntime,
+ thinkingLevel,
+ effectiveThinkingLevel: thinkingLevel ?? metadata.defaultLevel,
+ thinkingLevels: metadata.levels,
+ thinkingOptions: metadata.levels.map((level) => level.label),
+ thinkingDefault: metadata.defaultLevel,
+ };
+}
+
+/** Resolve the canonical runtime, selected level, and picker metadata for a session. */
+export function resolveGatewaySessionThinkingProjection(params: {
+ cfg: OpenClawConfig;
+ provider: string;
+ model: string;
+ agentId: string;
+ sessionKey: string;
+ entry?: SessionEntry;
+ modelCatalog?: ModelCatalogEntry[];
+}) {
+ return resolveGatewaySessionThinkingProjectionInternal(params);
}
export function getSessionDefaults(
@@ -1545,11 +1700,34 @@ export function getSessionDefaults(
cfg.agents?.defaults?.contextTokens ??
lookupContextTokens(resolved.model, { allowAsyncLoad: false }) ??
DEFAULT_CONTEXT_TOKENS;
- const thinkingLevels = listThinkingLevelOptions(resolved.provider, resolved.model, modelCatalog);
+ const agentId = normalizeAgentId(resolveDefaultAgentId(cfg));
+ const sessionKey = resolveAgentMainSessionKey({ cfg, agentId });
+ const agentRuntime = resolveModelAgentRuntimeMetadata({
+ cfg,
+ agentId,
+ provider: resolved.provider,
+ model: resolved.model,
+ sessionKey,
+ acpRuntime: false,
+ });
+ const thinkingRuntime = resolveEffectiveAgentRuntime({
+ cfg,
+ provider: resolved.provider,
+ modelId: resolved.model,
+ agentId,
+ sessionKey,
+ });
+ const thinkingLevels = listThinkingLevelOptions(
+ resolved.provider,
+ resolved.model,
+ modelCatalog,
+ thinkingRuntime,
+ );
return {
modelProvider: resolved.provider ?? null,
model: resolved.model ?? null,
contextTokens: contextTokens ?? null,
+ agentRuntime,
thinkingLevels,
thinkingOptions: thinkingLevels.map((level) => level.label),
thinkingDefault: resolveGatewaySessionThinkingDefault({
@@ -1557,6 +1735,7 @@ export function getSessionDefaults(
provider: resolved.provider,
model: resolved.model,
modelCatalog,
+ agentRuntime: thinkingRuntime,
}),
};
}
@@ -1912,16 +2091,6 @@ export function buildGatewaySessionRow(params: {
agentId: sessionAgentId,
sessionKey: key,
});
- const acpMeta = readAcpSessionMeta({ sessionKey: acpSessionKey });
- const agentRuntime = resolveModelAgentRuntimeMetadata({
- cfg,
- agentId: sessionAgentId,
- provider: rowModelProvider,
- model: rowModel,
- sessionKey: acpSessionKey,
- acpRuntime: acpMeta != null,
- acpBackend: acpMeta?.backend,
- });
const estimatedCostUsd = lightweight
? resolveNonNegativeNumber(entry?.estimatedCostUsd)
: (resolveEstimatedSessionCostUsd({
@@ -1972,16 +2141,16 @@ export function buildGatewaySessionRow(params: {
const thinkingProvider = rowModelProvider ?? DEFAULT_PROVIDER;
const thinkingModel = rowModel ?? DEFAULT_MODEL;
- const thinkingMetadata = resolveSessionRowThinkingMetadata({
+ const thinkingProjection = resolveGatewaySessionThinkingProjectionInternal({
cfg,
agentId: sessionAgentId,
provider: thinkingProvider,
model: thinkingModel,
+ sessionKey: acpSessionKey,
+ entry,
modelCatalog: params.modelCatalog,
rowContext,
});
- const thinkingLevels = thinkingMetadata.levels;
- const thinkingDefault = thinkingMetadata.defaultLevel;
const fastModeState = resolveFastModeState({
cfg,
provider: selectedOrRuntimeModelProvider ?? DEFAULT_PROVIDER,
@@ -2029,10 +2198,10 @@ export function buildGatewaySessionRow(params: {
sessionId: entry?.sessionId,
systemSent: entry?.systemSent,
abortedLastRun: entry?.abortedLastRun,
- thinkingLevel: entry?.thinkingLevel,
- thinkingLevels,
- thinkingOptions: thinkingLevels.map((level) => level.label),
- thinkingDefault,
+ thinkingLevel: thinkingProjection.thinkingLevel,
+ thinkingLevels: thinkingProjection.thinkingLevels,
+ thinkingOptions: thinkingProjection.thinkingOptions,
+ thinkingDefault: thinkingProjection.thinkingDefault,
fastMode: entry?.fastMode,
effectiveFastMode: fastModeState.mode,
effectiveFastModeSource: fastModeState.source,
@@ -2064,7 +2233,7 @@ export function buildGatewaySessionRow(params: {
),
modelProvider: rowModelProvider,
model: rowModel,
- agentRuntime,
+ agentRuntime: thinkingProjection.agentRuntime,
contextTokens,
contextBudgetStatus: entry?.contextBudgetStatus,
deliveryContext: deliveryFields.deliveryContext,
diff --git a/src/gateway/session-utils.types.ts b/src/gateway/session-utils.types.ts
index 68cac3e3c1c7..136046771802 100644
--- a/src/gateway/session-utils.types.ts
+++ b/src/gateway/session-utils.types.ts
@@ -24,6 +24,7 @@ export type GatewaySessionsDefaults = {
modelProvider: string | null;
model: string | null;
contextTokens: number | null;
+ agentRuntime?: GatewayAgentRuntime;
thinkingLevels?: GatewayThinkingLevelOption[];
thinkingOptions?: string[];
thinkingDefault?: string;
@@ -145,5 +146,7 @@ export type SessionsPatchResult = SessionsPatchResultBase & {
modelProvider?: string;
model?: string;
agentRuntime?: GatewayAgentRuntime;
+ thinkingLevel?: string;
+ thinkingLevels?: GatewayThinkingLevelOption[];
};
};
diff --git a/src/gateway/sessions-patch.test.ts b/src/gateway/sessions-patch.test.ts
index 188c628cc956..0cb2a4072a37 100644
--- a/src/gateway/sessions-patch.test.ts
+++ b/src/gateway/sessions-patch.test.ts
@@ -8,6 +8,14 @@ import { createEmptyPluginRegistry } from "../plugins/registry-empty.js";
import { resetPluginRuntimeStateForTest, setActivePluginRegistry } from "../plugins/runtime.js";
import { applySessionsPatchToStore } from "./sessions-patch.js";
+const acpSessionMetaMocks = vi.hoisted(() => ({
+ readAcpSessionMetaForEntry: vi.fn(),
+}));
+
+vi.mock("../acp/runtime/session-meta.js", () => ({
+ readAcpSessionMetaForEntry: acpSessionMetaMocks.readAcpSessionMetaForEntry,
+}));
+
const SUBAGENT_MODEL = "synthetic/hf:moonshotai/Kimi-K2.5";
const KIMI_SUBAGENT_KEY = "agent:kimi:subagent:child";
const MAIN_SESSION_KEY = "agent:main:main";
@@ -212,6 +220,7 @@ function createAllowlistedAnthropicModelCfg(): OpenClawConfig {
describe("gateway sessions patch", () => {
afterEach(() => {
+ acpSessionMetaMocks.readAcpSessionMetaForEntry.mockReset();
resetProviderAuthAliasMapCacheForTest();
resetPluginRuntimeStateForTest();
});
@@ -827,6 +836,116 @@ describe("gateway sessions patch", () => {
expect(entry.thinkingLevel).toBe("xhigh");
});
+ test("persists OpenClaw Luna Ultra through the runtime-aware provider profile", async () => {
+ const entry = expectPatchOk(
+ await runPatch({
+ cfg: {
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.6-luna" },
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "openclaw" } },
+ },
+ },
+ },
+ } as OpenClawConfig,
+ patch: { key: MAIN_SESSION_KEY, thinkingLevel: "ultra" },
+ loadGatewayModelCatalog: async () => [],
+ }),
+ );
+
+ expect(entry.thinkingLevel).toBe("ultra");
+ });
+
+ test("remaps stored Ultra to Max when a model patch selects Codex Luna", async () => {
+ const entry = expectPatchOk(
+ await runPatch({
+ cfg: {
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.6-sol" },
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "codex" } },
+ },
+ },
+ },
+ } as OpenClawConfig,
+ store: mainStoreEntry({ thinkingLevel: "ultra" }),
+ patch: { key: MAIN_SESSION_KEY, model: "openai/gpt-5.6-luna" },
+ loadGatewayModelCatalog: loadCatalog("openai/gpt-5.6-sol", "openai/gpt-5.6-luna"),
+ }),
+ );
+
+ expect(entry.thinkingLevel).toBe("max");
+ });
+
+ test("honors an explicit OpenClaw session runtime override for Luna Ultra", async () => {
+ const entry = expectPatchOk(
+ await runPatch({
+ cfg: {
+ agents: { defaults: { model: { primary: "openai/gpt-5.6-luna" } } },
+ } as OpenClawConfig,
+ store: mainStoreEntry({
+ agentRuntimeOverride: "openclaw",
+ agentHarnessId: "codex",
+ }),
+ patch: { key: MAIN_SESSION_KEY, thinkingLevel: "ultra" },
+ loadGatewayModelCatalog: async () => [],
+ }),
+ );
+
+ expect(entry.thinkingLevel).toBe("ultra");
+ });
+
+ test("uses ACP backend metadata on canonical agent keys for thinking validation", async () => {
+ acpSessionMetaMocks.readAcpSessionMetaForEntry.mockReturnValue({
+ backend: "codex",
+ agent: "main",
+ runtimeSessionName: MAIN_SESSION_KEY,
+ mode: "persistent",
+ state: "idle",
+ lastActivityAt: 1,
+ });
+
+ const result = await runPatch({
+ cfg: {
+ agents: {
+ defaults: {
+ model: { primary: "openai/gpt-5.6-luna" },
+ models: {
+ "openai/gpt-5.6-luna": { agentRuntime: { id: "openclaw" } },
+ },
+ },
+ },
+ } as OpenClawConfig,
+ store: mainStoreEntry({}),
+ patch: { key: MAIN_SESSION_KEY, thinkingLevel: "ultra" },
+ loadGatewayModelCatalog: async () => [],
+ });
+
+ expectPatchError(result, 'thinkingLevel "ultra" is not supported');
+ expect(acpSessionMetaMocks.readAcpSessionMetaForEntry).toHaveBeenCalledWith({
+ sessionKey: MAIN_SESSION_KEY,
+ entry: expect.objectContaining({ sessionId: "sess" }),
+ });
+ });
+
+ test("treats the persisted harness id as observational when validating Luna Ultra", async () => {
+ const result = await runPatch({
+ cfg: {
+ agents: { defaults: { model: { primary: "openai/gpt-5.6-luna" } } },
+ } as OpenClawConfig,
+ store: mainStoreEntry({ agentHarnessId: "openclaw" }),
+ patch: { key: MAIN_SESSION_KEY, thinkingLevel: "ultra" },
+ loadGatewayModelCatalog: async () => [],
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.message).toContain("not supported");
+ }
+ });
+
test("preserves an incompatible stored thinkingLevel without loading the catalog for unrelated patches", async () => {
const loadGatewayModelCatalog = vi.fn(async () => [
{ provider: "synthetic", id: "plain", name: "plain", reasoning: false },
diff --git a/src/gateway/sessions-patch.ts b/src/gateway/sessions-patch.ts
index 3ef2dd7d32dd..b9fb18f1ae83 100644
--- a/src/gateway/sessions-patch.ts
+++ b/src/gateway/sessions-patch.ts
@@ -10,6 +10,7 @@ import {
errorShape,
type SessionsPatchParams,
} from "../../packages/gateway-protocol/src/index.js";
+import { readAcpSessionMetaForEntry } from "../acp/runtime/session-meta.js";
import { resolveDefaultAgentId } from "../agents/agent-scope.js";
import {
normalizeInheritedToolAllowlist,
@@ -23,6 +24,7 @@ import {
resolveSubagentConfiguredModelSelection,
} from "../agents/model-selection.js";
import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js";
+import { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import {
formatThinkingLevels,
@@ -155,6 +157,26 @@ export async function projectSessionsPatchEntry(params: {
const subagentModelHint = isSubagentSessionKey(storeKey)
? resolveSubagentConfiguredModelSelection({ cfg, agentId: sessionAgentId })
: undefined;
+ const resolveThinkingRuntime = (
+ provider: string,
+ model: string,
+ entry?: SessionEntry,
+ ): string => {
+ // ACP metadata can own canonical agent keys (for example agent:main:main),
+ // so key shape alone cannot identify the runtime that validates thinking.
+ const acpMeta = readAcpSessionMetaForEntry({ sessionKey: storeKey, entry });
+ return (
+ acpMeta?.backend ??
+ resolveEffectiveAgentRuntime({
+ cfg,
+ provider,
+ modelId: model,
+ agentId: sessionAgentId,
+ sessionKey: storeKey,
+ sessionEntry: entry,
+ })
+ );
+ };
let loadedModelCatalog: ModelCatalogEntry[] | undefined;
const loadModelCatalogForPatch = async () => {
if (loadedModelCatalog) {
@@ -429,8 +451,9 @@ export async function projectSessionsPatchEntry(params: {
normalizeOptionalString(existing?.providerOverride) || resolvedDefault.provider;
const hintModel = normalizeOptionalString(existing?.modelOverride) || resolvedDefault.model;
const thinkingCatalog = await loadModelCatalogForPatch();
+ const thinkingRuntime = resolveThinkingRuntime(hintProvider, hintModel, existing);
return invalid(
- `invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|", thinkingCatalog)})`,
+ `invalid thinkingLevel (use ${formatThinkingLevels(hintProvider, hintModel, "|", thinkingCatalog, thinkingRuntime)})`,
);
}
next.thinkingLevel = normalized;
@@ -639,25 +662,30 @@ export async function projectSessionsPatchEntry(params: {
const thinkingCatalog = await loadModelCatalogForPatch();
if (!thinkingLevel) {
delete next.thinkingLevel;
- } else if (
- !isThinkingLevelSupported({
- provider: effectiveProvider,
- model: effectiveModel,
- level: thinkingLevel,
- catalog: thinkingCatalog,
- })
- ) {
- if ("thinkingLevel" in patch) {
- return invalid(
- `thinkingLevel "${thinkingLevel}" is not supported for ${effectiveProvider}/${effectiveModel} (use ${formatThinkingLevels(effectiveProvider, effectiveModel, "|", thinkingCatalog)})`,
- );
+ } else {
+ const thinkingRuntime = resolveThinkingRuntime(effectiveProvider, effectiveModel, next);
+ if (
+ !isThinkingLevelSupported({
+ provider: effectiveProvider,
+ model: effectiveModel,
+ level: thinkingLevel,
+ catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
+ })
+ ) {
+ if ("thinkingLevel" in patch) {
+ return invalid(
+ `thinkingLevel "${thinkingLevel}" is not supported for ${effectiveProvider}/${effectiveModel} (use ${formatThinkingLevels(effectiveProvider, effectiveModel, "|", thinkingCatalog, thinkingRuntime)})`,
+ );
+ }
+ next.thinkingLevel = resolveSupportedThinkingLevel({
+ provider: effectiveProvider,
+ model: effectiveModel,
+ level: thinkingLevel,
+ catalog: thinkingCatalog,
+ agentRuntime: thinkingRuntime,
+ });
}
- next.thinkingLevel = resolveSupportedThinkingLevel({
- provider: effectiveProvider,
- model: effectiveModel,
- level: thinkingLevel,
- catalog: thinkingCatalog,
- });
}
}
diff --git a/src/infra/heartbeat-runner.tool-response.test.ts b/src/infra/heartbeat-runner.tool-response.test.ts
index 86bb4c0fbaaa..1c3456a648cc 100644
--- a/src/infra/heartbeat-runner.tool-response.test.ts
+++ b/src/infra/heartbeat-runner.tool-response.test.ts
@@ -46,6 +46,7 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
agentRuntimeId?: string;
modelRuntimeId?: string;
model?: string;
+ isolatedSession?: boolean;
target?: "telegram" | "last";
showOk?: boolean;
}): OpenClawConfig {
@@ -53,7 +54,11 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
agents: {
defaults: {
workspace: params.tmpDir,
- heartbeat: { every: "5m", target: params.target ?? "telegram" },
+ heartbeat: {
+ every: "5m",
+ target: params.target ?? "telegram",
+ ...(params.isolatedSession ? { isolatedSession: true } : {}),
+ },
...(params.model ? { model: params.model } : {}),
...(params.model && params.modelRuntimeId
? { models: { [params.model]: { agentRuntime: { id: params.modelRuntimeId } } } }
@@ -121,12 +126,15 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
return call;
}
- function replyContext(replySpy: ReturnType): { Body?: string } {
+ function replyContext(replySpy: ReturnType): {
+ Body?: string;
+ SessionKey?: string;
+ } {
const context = replyCall(replySpy)[0];
if (!context || typeof context !== "object") {
throw new Error("Expected reply context");
}
- return context as { Body?: string };
+ return context as { Body?: string; SessionKey?: string };
}
function replyOptions(replySpy: ReturnType): {
@@ -331,28 +339,62 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
expectHeartbeatToolPrompt(result, ["notify=false"]);
});
- it("uses the heartbeat response tool prompt for Codex harness sessions by default", async () => {
- const result = await runPromptScenario({
- session: { agentHarnessId: "codex" },
- });
+ it("uses the heartbeat response tool prompt for the default Codex runtime", async () => {
+ const result = await runPromptScenario();
expectHeartbeatToolPrompt(result);
});
it.each([
- ["agentHarnessId", { agentHarnessId: "codex" }],
- ["agentRuntimeOverride", { agentRuntimeOverride: "codex" }],
- ])(
- "preserves persisted Codex runtime from %s for non-OpenAI heartbeat sessions",
- async (_field, session) => {
- const result = await runPromptScenario({
- config: { model: "anthropic/claude-sonnet-4-6" },
- session,
- });
-
- expectHeartbeatToolPrompt(result);
+ {
+ name: "uses the isolated Codex runtime instead of the base OpenClaw runtime",
+ config: { isolatedSession: true },
+ session: {
+ modelProvider: "anthropic",
+ model: "claude-sonnet-4-6",
+ agentRuntimeOverride: "openclaw",
+ },
+ expectedToolPrompt: true,
},
- );
+ {
+ name: "uses the isolated OpenClaw runtime instead of the base Codex runtime",
+ config: {
+ isolatedSession: true,
+ model: "anthropic/claude-sonnet-4-6",
+ },
+ session: {
+ modelProvider: "openai",
+ model: "gpt-5.6-sol",
+ agentRuntimeOverride: "codex",
+ },
+ expectedToolPrompt: false,
+ },
+ ])("$name", async ({ config, session, expectedToolPrompt }) => {
+ const result = await runPromptScenario({ config, session });
+
+ expect(result.calledCtx.SessionKey).toMatch(/:heartbeat$/);
+ if (expectedToolPrompt) {
+ expectHeartbeatToolPrompt(result);
+ return;
+ }
+ expect(result.calledCtx.Body).toContain("HEARTBEAT_OK");
+ expect(result.calledCtx.Body).not.toContain("heartbeat_respond");
+ expect(result.calledOpts.sourceReplyDeliveryMode).toBeUndefined();
+ });
+
+ it.each([
+ ["observational harness id", { agentHarnessId: "codex" }],
+ ["provider-incompatible override", { agentRuntimeOverride: "codex" }],
+ ])("does not let a %s select the next heartbeat runtime", async (_label, session) => {
+ const result = await runPromptScenario({
+ config: { model: "anthropic/claude-sonnet-4-6" },
+ session,
+ });
+
+ expect(result.calledCtx.Body).toContain("HEARTBEAT_OK");
+ expect(result.calledCtx.Body).not.toContain("heartbeat_respond");
+ expect(result.calledOpts.sourceReplyDeliveryMode).toBeUndefined();
+ });
it("delivers Codex runtime failure notices during Codex heartbeat message-tool mode", async () => {
await withTempTelegramHeartbeatSandbox(async ({ tmpDir, storePath, replySpy }) => {
@@ -457,7 +499,7 @@ describe("runHeartbeatOnce heartbeat response tool", () => {
const result = await runPromptScenario({
config: {
agentRuntimeId: "auto",
- model: "codex/gpt-5.5",
+ model: "openai/gpt-5.5",
},
});
diff --git a/src/infra/heartbeat-runner.ts b/src/infra/heartbeat-runner.ts
index 7bc691fc92ea..2c415e1bed2a 100644
--- a/src/infra/heartbeat-runner.ts
+++ b/src/infra/heartbeat-runner.ts
@@ -13,7 +13,6 @@ import {
isReasoningReplyPayload,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
-import { normalizeOptionalAgentRuntimeId } from "../agents/agent-runtime-id.js";
import {
listAgentIds,
resolveAgentConfig,
@@ -24,10 +23,9 @@ import { appendCronStyleCurrentTimeLine } from "../agents/current-time.js";
import { resolveEmbeddedSessionLane } from "../agents/embedded-agent-runner/lanes.js";
import { listActiveEmbeddedRunSessionKeys } from "../agents/embedded-agent-runner/run-state.js";
import { formatReasoningMessage } from "../agents/embedded-agent-utils.js";
-import { resolveAgentHarnessPolicy } from "../agents/harness/policy.js";
import { resolveModelRefFromString, type ModelRef } from "../agents/model-selection.js";
-import { resolvePersistedSessionRuntimeId } from "../agents/session-runtime-compat.js";
import { STREAM_ERROR_FALLBACK_TEXT } from "../agents/stream-message-shared.js";
+import { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js";
import { DEFAULT_HEARTBEAT_FILENAME } from "../agents/workspace.js";
import { resolveHeartbeatReplyPayload } from "../auto-reply/heartbeat-reply-payload.js";
import {
@@ -482,8 +480,14 @@ function resolveHeartbeatModelRef(params: {
return heartbeatRef;
}
return {
- provider: normalizeOptionalString(params.entry?.modelProvider) ?? defaultProvider,
- model: normalizeOptionalString(params.entry?.model) ?? defaultModel,
+ provider:
+ normalizeOptionalString(params.entry?.providerOverride) ??
+ normalizeOptionalString(params.entry?.modelProvider) ??
+ defaultProvider,
+ model:
+ normalizeOptionalString(params.entry?.modelOverride) ??
+ normalizeOptionalString(params.entry?.model) ??
+ defaultModel,
};
}
@@ -492,29 +496,19 @@ function usesCodexHarness(params: {
agentId: string;
heartbeat?: HeartbeatConfig;
entry?: SessionEntry;
+ sessionKey?: string;
}): boolean {
- const persistedRuntimeId = resolvePersistedSessionRuntimeId(params.entry);
- if (persistedRuntimeId === "codex") {
- return true;
- }
- if (persistedRuntimeId && persistedRuntimeId !== "auto") {
- return false;
- }
const modelRef = resolveHeartbeatModelRef(params);
- const policy = resolveAgentHarnessPolicy({
- config: params.cfg,
- provider: modelRef.provider,
- modelId: modelRef.model,
- agentId: params.agentId,
- });
- const runtimeId = normalizeOptionalAgentRuntimeId(policy.runtime);
- if (runtimeId === "codex") {
- return true;
- }
- if (runtimeId && runtimeId !== "auto") {
- return false;
- }
- return normalizeLowercaseStringOrEmpty(modelRef.provider) === "codex";
+ return (
+ resolveEffectiveAgentRuntime({
+ cfg: params.cfg,
+ provider: modelRef.provider,
+ modelId: modelRef.model,
+ agentId: params.agentId,
+ sessionKey: params.sessionKey,
+ sessionEntry: params.entry,
+ }) === "codex"
+ );
}
function shouldUseHeartbeatResponseToolPrompt(params: {
@@ -522,6 +516,7 @@ function shouldUseHeartbeatResponseToolPrompt(params: {
agentId: string;
heartbeat?: HeartbeatConfig;
entry?: SessionEntry;
+ sessionKey?: string;
chatType?: ChatType;
}): boolean {
const chatType = normalizeChatType(params.chatType);
@@ -1653,21 +1648,15 @@ export async function runHeartbeatOnce(opts: {
delivery.channel !== "none" && delivery.to && visibility.showAlerts,
);
const workspaceDir = resolveAgentWorkspaceDir(cfg, agentId);
- const useHeartbeatResponseToolPrompt = shouldUseHeartbeatResponseToolPrompt({
+ let useHeartbeatResponseToolPrompt = shouldUseHeartbeatResponseToolPrompt({
cfg,
agentId,
heartbeat,
entry,
+ sessionKey,
chatType: delivery.chatType,
});
- const {
- prompt,
- hasExecCompletion,
- hasRelayableExecCompletion,
- hasCronEvents,
- hasDueCommitments,
- usesHeartbeatResponseTool,
- } = resolveHeartbeatRunPrompt({
+ let heartbeatRunPrompt = resolveHeartbeatRunPrompt({
cfg,
heartbeat,
preflight,
@@ -1679,28 +1668,25 @@ export async function runHeartbeatOnce(opts: {
useHeartbeatResponseTool: useHeartbeatResponseToolPrompt,
runScope,
});
- const dueCommitmentIds = hasDueCommitments
- ? preflight.dueCommitments.map((commitment) => commitment.id)
- : [];
- const inspectedSystemEventsToConsume = selectSystemEventsConsumedByHeartbeat({
- preflight,
- hasExecCompletion,
- hasCronEvents,
- });
// If no tasks are due, skip heartbeat entirely
- if (prompt === null) {
+ if (heartbeatRunPrompt.prompt === null) {
// Wake-triggered events should stay queued when the run short-circuits:
// no reply turn ran, so there is nothing that actually consumed that wake payload.
const shouldConsumeInspectedEvents =
!preflight.isWakePayload && preflight.shouldInspectPendingEvents;
+ const inspectedSystemEventsToConsume = selectSystemEventsConsumedByHeartbeat({
+ preflight,
+ hasExecCompletion: heartbeatRunPrompt.hasExecCompletion,
+ hasCronEvents: heartbeatRunPrompt.hasCronEvents,
+ });
if (shouldConsumeInspectedEvents && inspectedSystemEventsToConsume.length > 0) {
consumeSelectedSystemEventEntries(sessionKey, inspectedSystemEventsToConsume);
}
return { status: "skipped", reason: "no-tasks-due" };
}
-
let runSessionKey = sessionKey;
+ let runSessionEntry = entry;
let outboundPolicySessionKey: string | undefined;
if (useIsolatedSession) {
const configuredSession = resolveHeartbeatSession(cfg, agentId, heartbeat);
@@ -1760,10 +1746,12 @@ export async function runHeartbeatOnce(opts: {
forceNew: true,
store,
});
- return {
+ const nextEntry = {
...cronSession.sessionEntry,
heartbeatIsolatedBaseSessionKey: isolatedBaseSessionKey,
};
+ runSessionEntry = nextEntry;
+ return nextEntry;
},
},
],
@@ -1778,7 +1766,50 @@ export async function runHeartbeatOnce(opts: {
}
runSessionKey = isolatedSessionKey;
outboundPolicySessionKey = isolatedBaseSessionKey;
+
+ const actualUseHeartbeatResponseToolPrompt = shouldUseHeartbeatResponseToolPrompt({
+ cfg,
+ agentId,
+ heartbeat,
+ entry: runSessionEntry,
+ sessionKey: runSessionKey,
+ chatType: delivery.chatType,
+ });
+ if (actualUseHeartbeatResponseToolPrompt !== useHeartbeatResponseToolPrompt) {
+ useHeartbeatResponseToolPrompt = actualUseHeartbeatResponseToolPrompt;
+ heartbeatRunPrompt = resolveHeartbeatRunPrompt({
+ cfg,
+ heartbeat,
+ preflight,
+ canRelayToUser,
+ workspaceDir,
+ startedAt,
+ dueTasks: dueHeartbeatTasks,
+ heartbeatFileContent: preflight.heartbeatFileContent,
+ useHeartbeatResponseTool: useHeartbeatResponseToolPrompt,
+ runScope,
+ });
+ }
}
+ const {
+ hasExecCompletion,
+ hasRelayableExecCompletion,
+ hasCronEvents,
+ hasDueCommitments,
+ usesHeartbeatResponseTool,
+ } = heartbeatRunPrompt;
+ const prompt = heartbeatRunPrompt.prompt;
+ if (prompt === null) {
+ return { status: "skipped", reason: "no-tasks-due" };
+ }
+ const dueCommitmentIds = hasDueCommitments
+ ? preflight.dueCommitments.map((commitment) => commitment.id)
+ : [];
+ const inspectedSystemEventsToConsume = selectSystemEventsConsumedByHeartbeat({
+ preflight,
+ hasExecCompletion,
+ hasCronEvents,
+ });
// Update task last run times AFTER successful heartbeat completion
const updateTaskTimestamps = async () => {
if (!preflight.tasks || preflight.tasks.length === 0 || dueHeartbeatTasks.length === 0) {
diff --git a/src/llm/providers/stream-wrappers/reasoning-effort-utils.test.ts b/src/llm/providers/stream-wrappers/reasoning-effort-utils.test.ts
index 33660925a755..bc050a28d5e4 100644
--- a/src/llm/providers/stream-wrappers/reasoning-effort-utils.test.ts
+++ b/src/llm/providers/stream-wrappers/reasoning-effort-utils.test.ts
@@ -15,6 +15,10 @@ describe("mapThinkingLevelToReasoningEffort", () => {
expect(mapThinkingLevelToReasoningEffort("max")).toBe("xhigh");
});
+ it('defensively maps logical "ultra" to the strongest generic wire effort', () => {
+ expect(mapThinkingLevelToReasoningEffort("ultra")).toBe("xhigh");
+ });
+
it.each(["minimal", "low", "medium", "high", "xhigh"] as const)(
"passes through %s unchanged",
(level) => {
diff --git a/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts b/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts
index dffd74ebec22..8a9a36579199 100644
--- a/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts
+++ b/src/llm/providers/stream-wrappers/reasoning-effort-utils.ts
@@ -12,7 +12,7 @@ export function mapThinkingLevelToReasoningEffort(thinkingLevel: ThinkLevel): Re
if (thinkingLevel === "adaptive") {
return "medium";
}
- if (thinkingLevel === "max") {
+ if (thinkingLevel === "max" || thinkingLevel === "ultra") {
return "xhigh";
}
return thinkingLevel;
diff --git a/src/plugin-sdk/command-auth-native.ts b/src/plugin-sdk/command-auth-native.ts
index 213ca839bdb3..730446979c03 100644
--- a/src/plugin-sdk/command-auth-native.ts
+++ b/src/plugin-sdk/command-auth-native.ts
@@ -37,6 +37,7 @@ export {
type CommandAuthorization,
} from "../auto-reply/command-auth.js";
export { resolveStoredModelOverride } from "../auto-reply/reply/stored-model-override.js";
+export { resolveEffectiveAgentRuntime } from "../agents/thinking-runtime.js";
export {
formatFastModeCommandOptions,
formatFastModeCurrentStatus,
diff --git a/src/plugin-sdk/provider-stream-shared.test.ts b/src/plugin-sdk/provider-stream-shared.test.ts
index e22263f388cc..490d7736cf5c 100644
--- a/src/plugin-sdk/provider-stream-shared.test.ts
+++ b/src/plugin-sdk/provider-stream-shared.test.ts
@@ -235,6 +235,14 @@ describe("normalizeOpenAICompatibleReasoningPayload", () => {
expect(payload).toEqual({});
});
+
+ it("defensively normalizes logical Ultra for generic compatible payloads", () => {
+ const payload: Record = {};
+
+ normalizeOpenAICompatibleReasoningPayload(payload, "ultra");
+
+ expect(payload).toEqual({ reasoning: { effort: "xhigh" } });
+ });
});
describe("createDeepSeekV4OpenAICompatibleThinkingWrapper", () => {
diff --git a/src/plugin-sdk/provider-stream.test.ts b/src/plugin-sdk/provider-stream.test.ts
index 4d915ee36fb9..48e20cd6928c 100644
--- a/src/plugin-sdk/provider-stream.test.ts
+++ b/src/plugin-sdk/provider-stream.test.ts
@@ -321,9 +321,11 @@ describe("buildProviderStreamFamilyHooks", () => {
expect(capturedModelReasoning).toBe(true);
const openAiHooks = OPENAI_RESPONSES_STREAM_HOOKS;
+ payloadSeed = { reasoning: { effort: "medium", summary: "auto" } };
void requireStreamFn(
requireWrapStreamFn(openAiHooks.wrapStreamFn)({
streamFn: baseStreamFn,
+ thinkingLevel: "max",
extraParams: { serviceTier: "flex" },
config: {},
agentDir: "/tmp/provider-stream-test",
@@ -333,13 +335,15 @@ describe("buildProviderStreamFamilyHooks", () => {
api: "openai-responses",
provider: "openai",
baseUrl: "https://api.openai.com/v1",
- id: "gpt-5.4",
+ id: "gpt-5.6-sol",
+ thinkingLevelMap: { max: "max" },
} as never,
{} as never,
{},
);
const openAiPayload = requirePayload(capturedPayload);
expectDefaultThinkingBudget(openAiPayload);
+ expect(openAiPayload.reasoning).toEqual({ effort: "max", summary: "auto" });
expect(openAiPayload.service_tier).toBe("flex");
expect(capturedHeaders).toEqual({
"User-Agent": `openclaw/${VERSION}`,
diff --git a/src/plugins/provider-thinking.types.ts b/src/plugins/provider-thinking.types.ts
index 231812930722..b7fc7a800cb0 100644
--- a/src/plugins/provider-thinking.types.ts
+++ b/src/plugins/provider-thinking.types.ts
@@ -27,6 +27,8 @@ export type ProviderThinkingModelCompat = {
* profiles only when the configured payload style supports them.
*/
export type ProviderDefaultThinkingPolicyContext = ProviderThinkingPolicyContext & {
+ /** Effective agent runtime selected for this model, when known. */
+ agentRuntime?: string | null;
reasoning?: boolean;
params?: Record;
compat?: ProviderThinkingModelCompat | null;
@@ -40,7 +42,8 @@ export type ProviderThinkingLevelId =
| "high"
| "xhigh"
| "adaptive"
- | "max";
+ | "max"
+ | "ultra";
export type ProviderThinkingLevel = {
id: ProviderThinkingLevelId;
diff --git a/src/plugins/runtime/runtime-agent.ts b/src/plugins/runtime/runtime-agent.ts
index eff95c94447f..c4edb672df36 100644
--- a/src/plugins/runtime/runtime-agent.ts
+++ b/src/plugins/runtime/runtime-agent.ts
@@ -6,6 +6,10 @@ import {
buildConfiguredModelCatalog,
resolveThinkingDefault,
} from "../../agents/model-selection.js";
+import {
+ concretizeAgentRuntime,
+ resolveEffectiveAgentRuntime,
+} from "../../agents/thinking-runtime.js";
import { resolveAgentTimeoutMs } from "../../agents/timeout.js";
import { ensureAgentWorkspace } from "../../agents/workspace.js";
import { normalizeThinkLevel, resolveThinkingProfile } from "../../auto-reply/thinking.js";
@@ -220,8 +224,19 @@ export function createRuntimeAgent(): PluginRuntime["agent"] {
resolveThinkingDefault,
normalizeThinkingLevel: normalizeThinkLevel,
resolveThinkingPolicy: (params) => {
+ const cfg = getRuntimeConfig();
+ const effectiveRuntime = params.agentRuntime
+ ? concretizeAgentRuntime(params.agentRuntime)
+ : params.provider && params.model
+ ? resolveEffectiveAgentRuntime({
+ cfg,
+ provider: params.provider,
+ modelId: params.model,
+ })
+ : undefined;
const profile = resolveThinkingProfile({
...params,
+ agentRuntime: effectiveRuntime,
catalog: resolveRuntimeThinkingCatalog(params),
});
const policy: Omit<
diff --git a/src/plugins/runtime/types-core.ts b/src/plugins/runtime/types-core.ts
index e51fd2ca7fb4..d8b1381bb29e 100644
--- a/src/plugins/runtime/types-core.ts
+++ b/src/plugins/runtime/types-core.ts
@@ -105,6 +105,7 @@ export type PluginRuntimeThinkingPolicyRequest = {
provider?: string | null;
model?: string | null;
catalog?: import("../../auto-reply/thinking.js").ThinkingCatalogEntry[];
+ agentRuntime?: string | null;
};
export type PluginRuntimeThinkingPolicyLevel = {
id: import("../../auto-reply/thinking.js").ThinkLevel;
diff --git a/src/plugins/types.ts b/src/plugins/types.ts
index 69bce266fed9..4c68557c2334 100644
--- a/src/plugins/types.ts
+++ b/src/plugins/types.ts
@@ -722,6 +722,9 @@ export type ProviderAuthDoctorHintContext = {
* Use this to set provider defaults or rewrite provider-specific config keys
* into the merged `extraParams` object. Return the full next extraParams object.
*/
+/** Provider-facing effort after OpenClaw lowers orchestration-only modes. */
+export type ProviderTransportThinkingLevel = Exclude;
+
export type ProviderPrepareExtraParamsContext = {
config?: OpenClawConfig;
agentDir?: string;
@@ -732,7 +735,7 @@ export type ProviderPrepareExtraParamsContext = {
modelId: string;
model?: ProviderRuntimeModel;
extraParams?: Record;
- thinkingLevel?: ThinkLevel;
+ thinkingLevel?: ProviderTransportThinkingLevel;
};
export type ProviderExtraParamsForTransportContext = Omit<
diff --git a/src/status/status-text.ts b/src/status/status-text.ts
index d64fdd492f0a..ae692f1fe084 100644
--- a/src/status/status-text.ts
+++ b/src/status/status-text.ts
@@ -20,13 +20,14 @@ import {
import { resolveDefaultModelForAgent } from "../agents/model-selection.js";
import { listOpenAIAuthProfileProvidersForAgentRuntime } from "../agents/openai-routing.js";
import { resolveProviderIdForAuth } from "../agents/provider-auth-aliases.js";
+import { resolveSessionRuntimeOverrideForProvider } from "../agents/session-runtime-compat.js";
import {
resolveInternalSessionKey,
resolveMainSessionAlias,
} from "../agents/tools/sessions-helpers.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import { resolveSelectedAndActiveModel } from "../auto-reply/model-runtime.js";
-import type { ThinkLevel } from "../auto-reply/thinking.js";
+import { resolveSupportedThinkingLevel, type ThinkLevel } from "../auto-reply/thinking.js";
import { toAgentModelListLike } from "../config/model-input.js";
import type { SessionEntry } from "../config/sessions.js";
import { hasSessionAutoModelFallbackProvenance } from "../config/sessions/model-override-provenance.js";
@@ -215,13 +216,18 @@ async function resolveStatusHarnessId(params: {
}): Promise {
try {
const { selectAgentHarness } = await loadAgentHarnessSelectionRuntime();
+ const agentHarnessRuntimeOverride = resolveSessionRuntimeOverrideForProvider({
+ provider: params.provider,
+ entry: params.sessionEntry,
+ cfg: params.cfg,
+ });
const selected = selectAgentHarness({
provider: params.provider,
modelId: params.model,
config: params.cfg,
agentId: params.agentId,
sessionKey: params.sessionKey,
- agentHarnessId: params.sessionEntry?.agentHarnessId,
+ agentHarnessRuntimeOverride,
});
const id = normalizeOptionalLowercaseString(selected.id);
return id || undefined;
@@ -593,6 +599,18 @@ export async function buildStatusText(params: BuildStatusTextParams): Promise {
]);
});
+ it.each([
+ { model: "gpt-5.6-sol", agentRuntime: "codex", supportsUltra: true },
+ { model: "gpt-5.6-terra", agentRuntime: "codex", supportsUltra: true },
+ { model: "gpt-5.6-luna", agentRuntime: "codex", supportsUltra: false },
+ { model: "gpt-5.6-luna", agentRuntime: "openclaw", supportsUltra: true },
+ ])(
+ "uses the $agentRuntime profile for openai/$model thinking completions",
+ ({ model, agentRuntime, supportsUltra }) => {
+ const think = getSlashCommands({
+ provider: "openai",
+ model,
+ agentRuntime,
+ thinkingLevels: [],
+ }).find((command) => command.name === "think");
+ const completions = think?.getArgumentCompletions?.("");
+ if (!Array.isArray(completions)) {
+ throw new Error("expected synchronous thinking-level completions");
+ }
+
+ expect(completions.some((choice) => choice.value === "ultra")).toBe(supportsUltra);
+ },
+ );
+
it("merges dynamic gateway commands", () => {
const commands = getSlashCommands({
dynamicCommands: [
diff --git a/src/tui/commands.ts b/src/tui/commands.ts
index 99eec5e9d144..043f388415c8 100644
--- a/src/tui/commands.ts
+++ b/src/tui/commands.ts
@@ -27,6 +27,7 @@ export type SlashCommandOptions = {
cfg?: OpenClawConfig;
provider?: string;
model?: string;
+ agentRuntime?: string;
thinkingLevels?: Array<{ id: string; label: string }>;
local?: boolean;
dynamicCommands?: CommandEntry[];
@@ -99,7 +100,7 @@ export function isSharedTextCommand(input: string): boolean {
export function getSlashCommands(options: SlashCommandOptions = {}): SlashCommand[] {
const thinkLevels = options.thinkingLevels?.length
? options.thinkingLevels.map((level) => level.label)
- : listThinkingLevelLabels(options.provider, options.model);
+ : listThinkingLevelLabels(options.provider, options.model, undefined, options.agentRuntime);
const verboseCompletions = createLevelCompletion(VERBOSE_LEVELS);
const traceCompletions = createLevelCompletion(TRACE_LEVELS);
const fastCompletions = createLevelCompletion(FAST_LEVELS);
@@ -205,7 +206,13 @@ export function getSlashCommands(options: SlashCommandOptions = {}): SlashComman
}
export function helpText(options: SlashCommandOptions = {}): string {
- const thinkLevels = formatThinkingLevels(options.provider, options.model, "|");
+ const thinkLevels = formatThinkingLevels(
+ options.provider,
+ options.model,
+ "|",
+ undefined,
+ options.agentRuntime,
+ );
return [
"Slash commands:",
"/help",
diff --git a/src/tui/tui-backend.ts b/src/tui/tui-backend.ts
index 8bee7e6d6e72..dae793343f2f 100644
--- a/src/tui/tui-backend.ts
+++ b/src/tui/tui-backend.ts
@@ -96,6 +96,7 @@ export type TuiSessionList = {
| "totalTokensFresh"
| "goal"
| "modelProvider"
+ | "agentRuntime"
| "displayName"
> & {
key: string;
@@ -156,6 +157,9 @@ export type TuiSessionMutationResult = {
resolved?: {
modelProvider?: string;
model?: string;
+ agentRuntime?: SessionInfo["agentRuntime"];
+ thinkingLevel?: string;
+ thinkingLevels?: SessionInfo["thinkingLevels"];
};
};
diff --git a/src/tui/tui-command-handlers.test.ts b/src/tui/tui-command-handlers.test.ts
index 50b76c15281d..85ec9c97aab2 100644
--- a/src/tui/tui-command-handlers.test.ts
+++ b/src/tui/tui-command-handlers.test.ts
@@ -6,6 +6,7 @@ import {
TUI_RECENT_SESSIONS_ACTIVE_MINUTES,
TUI_SESSION_PICKER_LIMIT,
} from "./tui-session-list-policy.js";
+import type { SessionInfo } from "./tui-types.js";
type LoadHistoryMock = ReturnType & (() => Promise);
type RunAuthFlow = NonNullable[0]["runAuthFlow"]>;
@@ -99,6 +100,7 @@ function createHarness(params?: {
currentSessionId?: string | null;
currentAgentId?: string;
currentSessionKey?: string;
+ sessionInfo?: SessionInfo;
abortActive?: AbortActiveMock;
consumeCompletedRunForPendingSend?: ConsumeCompletedRunMock;
isRunObserved?: (runId: string) => boolean;
@@ -159,7 +161,7 @@ function createHarness(params?: {
pendingSubmitDraft: null as { runId: string; text: string } | null,
activityStatus: params?.activityStatus ?? "idle",
isConnected: params?.isConnected ?? true,
- sessionInfo: {},
+ sessionInfo: params?.sessionInfo ?? {},
};
const { handleCommand, sendMessage, openSessionSelector } = createCommandHandlers({
@@ -1137,6 +1139,28 @@ describe("tui command handlers", () => {
});
});
+ it("uses the effective runtime for the no-arg /think usage", async () => {
+ const codex = createHarness({
+ sessionInfo: {
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "codex", source: "model" },
+ },
+ });
+ await codex.handleCommand("/think");
+ expect(codex.addSystem).toHaveBeenCalledWith(expect.not.stringContaining("ultra"));
+
+ const openclaw = createHarness({
+ sessionInfo: {
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "openclaw", source: "session-key" },
+ },
+ });
+ await openclaw.handleCommand("/think");
+ expect(openclaw.addSystem).toHaveBeenCalledWith(expect.stringContaining("ultra"));
+ });
+
it("hides tools locally for /verbose off without reloading history", async () => {
const patchResult = { entry: { verboseLevel: "off" } };
const patchSession = vi.fn().mockResolvedValue(patchResult);
@@ -1587,6 +1611,24 @@ describe("tui command handlers", () => {
expect(closeOverlay).toHaveBeenCalledTimes(1);
});
+ it.each(["codex", "openclaw"])(
+ "forwards model/runtime transactions through the server directive path for %s",
+ async (runtime) => {
+ const sendChat = vi.fn().mockResolvedValue({ status: "ok" });
+ const patchSession = vi.fn();
+ const command = `/model openai/gpt-5.6-luna --runtime ${runtime} continue with this model`;
+ const { handleCommand } = createHarness({ sendChat, patchSession });
+
+ await handleCommand(command);
+
+ expectSendChatFields(sendChat, {
+ message: command,
+ sessionKey: "agent:main:main",
+ });
+ expect(patchSession).not.toHaveBeenCalled();
+ },
+ );
+
it("shows resolved canonical model ref after /model alias, not raw alias string", async () => {
// When the user types `/model gpt4` (a bare alias), the gateway resolves it
// server-side and returns the canonical ref in result.resolved. The TUI must
diff --git a/src/tui/tui-command-handlers.ts b/src/tui/tui-command-handlers.ts
index 01d2d23665d8..0529a3e1dc2b 100644
--- a/src/tui/tui-command-handlers.ts
+++ b/src/tui/tui-command-handlers.ts
@@ -3,6 +3,7 @@ import { randomUUID } from "node:crypto";
import type { Component, OverlayHandle, SelectItem, TUI } from "@earendil-works/pi-tui";
import type { SessionsPatchResult } from "../../packages/gateway-protocol/src/index.js";
import { modelKey } from "../agents/model-ref-shared.js";
+import { shouldForwardModelCommandToServer } from "../auto-reply/commands-registry.shared.js";
import { normalizeGroupActivation } from "../auto-reply/group-activation.js";
import {
formatGoalContinuationPrompt,
@@ -373,6 +374,7 @@ export function createCommandHandlers(context: CommandHandlerContext) {
local: opts.local,
provider: state.sessionInfo.modelProvider,
model: state.sessionInfo.model,
+ agentRuntime: state.sessionInfo.agentRuntime?.id,
}),
);
break;
@@ -502,7 +504,9 @@ export function createCommandHandlers(context: CommandHandlerContext) {
await openSessionSelector();
break;
case "model":
- if (!args) {
+ if (shouldForwardModelCommandToServer(args)) {
+ await sendMessage(raw);
+ } else if (!args) {
await openModelSelector();
} else {
try {
@@ -532,7 +536,13 @@ export function createCommandHandlers(context: CommandHandlerContext) {
if (!args) {
const levels =
state.sessionInfo.thinkingLevels?.map((level) => level.label).join("|") ||
- formatThinkingLevels(state.sessionInfo.modelProvider, state.sessionInfo.model, "|");
+ formatThinkingLevels(
+ state.sessionInfo.modelProvider,
+ state.sessionInfo.model,
+ "|",
+ undefined,
+ state.sessionInfo.agentRuntime?.id,
+ );
chatLog.addSystem(`usage: /think <${levels}>`);
break;
}
diff --git a/src/tui/tui-session-actions.test.ts b/src/tui/tui-session-actions.test.ts
index af8ec21b80d3..3d5ea1b2880b 100644
--- a/src/tui/tui-session-actions.test.ts
+++ b/src/tui/tui-session-actions.test.ts
@@ -302,6 +302,41 @@ describe("tui session actions", () => {
expect(state.sessionInfo.updatedAt).toBe(200);
});
+ it("applies the runtime-aware thinking projection returned by session patches", () => {
+ const state = createBaseState();
+ const { applySessionInfoFromPatch } = createTestSessionActions({ state });
+
+ applySessionInfoFromPatch({
+ ok: true,
+ path: "/tmp/sessions.json",
+ key: "agent:main:main",
+ entry: { sessionId: "session-1", updatedAt: 200 },
+ resolved: {
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "openclaw", source: "session-key" },
+ thinkingLevel: "ultra",
+ thinkingLevels: [
+ { id: "off", label: "off" },
+ { id: "ultra", label: "ultra" },
+ ],
+ },
+ });
+
+ expect(state.sessionInfo).toEqual(
+ expect.objectContaining({
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "openclaw", source: "session-key" },
+ thinkingLevel: "ultra",
+ thinkingLevels: [
+ { id: "off", label: "off" },
+ { id: "ultra", label: "ultra" },
+ ],
+ }),
+ );
+ });
+
it("clears the footer goal when the current session has no row yet", async () => {
const listSessions = vi.fn().mockResolvedValue({
ts: Date.now(),
diff --git a/src/tui/tui-session-actions.ts b/src/tui/tui-session-actions.ts
index 054f4021d441..60a547b2f770 100644
--- a/src/tui/tui-session-actions.ts
+++ b/src/tui/tui-session-actions.ts
@@ -73,6 +73,16 @@ function goalEquals(left: SessionInfo["goal"], right: SessionInfo["goal"]): bool
return left === right || JSON.stringify(left ?? null) === JSON.stringify(right ?? null);
}
+function agentRuntimeEquals(
+ left: SessionInfo["agentRuntime"],
+ right: SessionInfo["agentRuntime"],
+): boolean {
+ return (
+ left === right ||
+ (left?.id === right?.id && left?.source === right?.source && left?.fallback === right?.fallback)
+ );
+}
+
function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): boolean {
return (
left.thinkingLevel === right.thinkingLevel &&
@@ -83,6 +93,7 @@ function sessionInfoUiEquals(left: SessionInfo, right: SessionInfo): boolean {
left.reasoningLevel === right.reasoningLevel &&
left.model === right.model &&
left.modelProvider === right.modelProvider &&
+ agentRuntimeEquals(left.agentRuntime, right.agentRuntime) &&
left.contextTokens === right.contextTokens &&
left.inputTokens === right.inputTokens &&
left.outputTokens === right.outputTokens &&
@@ -229,6 +240,9 @@ export function createSessionActions(context: SessionActionContext) {
if (entry?.thinkingLevels !== undefined || defaults?.thinkingLevels !== undefined) {
next.thinkingLevels = entry?.thinkingLevels ?? defaults?.thinkingLevels;
}
+ if (entry?.agentRuntime !== undefined) {
+ next.agentRuntime = entry.agentRuntime;
+ }
if (entry?.fastMode !== undefined) {
next.fastMode = entry.fastMode;
}
@@ -381,14 +395,16 @@ export function createSessionActions(context: SessionActionContext) {
updateHeader();
}
const resolved = result.resolved;
- const entry =
- resolved && (resolved.modelProvider || resolved.model)
- ? {
- ...result.entry,
- modelProvider: resolved.modelProvider ?? result.entry.modelProvider,
- model: resolved.model ?? result.entry.model,
- }
- : result.entry;
+ const entry = resolved
+ ? {
+ ...result.entry,
+ modelProvider: resolved.modelProvider ?? result.entry.modelProvider,
+ model: resolved.model ?? result.entry.model,
+ ...(resolved.agentRuntime ? { agentRuntime: resolved.agentRuntime } : {}),
+ ...(resolved.thinkingLevel ? { thinkingLevel: resolved.thinkingLevel } : {}),
+ ...(resolved.thinkingLevels ? { thinkingLevels: resolved.thinkingLevels } : {}),
+ }
+ : result.entry;
applySessionInfo({ entry, force: true });
};
diff --git a/src/tui/tui-types.ts b/src/tui/tui-types.ts
index f608b45ed2df..a64774ef5398 100644
--- a/src/tui/tui-types.ts
+++ b/src/tui/tui-types.ts
@@ -1,6 +1,7 @@
import type { FastMode } from "@openclaw/normalization-core/string-coerce";
// Defines shared TUI state, backend, and event types.
import type { SessionGoal } from "../config/sessions/types.js";
+import type { GatewayAgentRuntime } from "../shared/session-types.js";
export type TuiOptions = {
local?: boolean;
@@ -84,6 +85,7 @@ export type SessionInfo = {
reasoningLevel?: string;
model?: string;
modelProvider?: string;
+ agentRuntime?: GatewayAgentRuntime;
contextTokens?: number | null;
inputTokens?: number | null;
outputTokens?: number | null;
diff --git a/src/tui/tui.ts b/src/tui/tui.ts
index cfbe017caf84..890f8973cdcd 100644
--- a/src/tui/tui.ts
+++ b/src/tui/tui.ts
@@ -823,6 +823,7 @@ export async function runTui(opts: RunTuiOptions): Promise {
local: isLocalMode,
provider: sessionInfo.modelProvider,
model: sessionInfo.model,
+ agentRuntime: sessionInfo.agentRuntime?.id,
thinkingLevels: sessionInfo.thinkingLevels,
dynamicCommands: dynamicSlashCommandsKey === dynamicKey ? dynamicSlashCommands : [],
}),
diff --git a/test/scripts/package-acceptance-workflow.test.ts b/test/scripts/package-acceptance-workflow.test.ts
index ff35cb9dd436..b7e90eaa0deb 100644
--- a/test/scripts/package-acceptance-workflow.test.ts
+++ b/test/scripts/package-acceptance-workflow.test.ts
@@ -1142,11 +1142,25 @@ describe("package artifact reuse", () => {
expect(mimo.command).not.toContain("opencode-go/mimo-v2-pro");
});
+ it("runs the fresh OpenAI API-key default without hard-coding a model filter", () => {
+ const openaiDefault = workflowMatrixEntry(
+ LIVE_E2E_WORKFLOW,
+ "validate_live_provider_suites",
+ "native-live-src-gateway-profiles-openai-api-default",
+ );
+
+ expect(openaiDefault).toMatchObject({ profiles: "stable full" });
+ expect(openaiDefault.command).toContain("OPENCLAW_LIVE_GATEWAY_OPENAI_API_DEFAULT=1");
+ expect(openaiDefault.command).toContain("OPENCLAW_LIVE_GATEWAY_PROVIDERS=openai");
+ expect(openaiDefault.command).not.toContain("OPENCLAW_LIVE_GATEWAY_MODELS=");
+ });
+
it("runs Docker live harnesses from trusted helper scripts", () => {
const workflow = readFileSync(LIVE_E2E_WORKFLOW, "utf8");
const scenarios = readFileSync("scripts/lib/docker-e2e-scenarios.mjs", "utf8");
const scheduler = readFileSync("scripts/test-docker-all.mjs", "utf8");
const harness = readFileSync("scripts/test-live-codex-harness-docker.sh", "utf8");
+ const codexLiveTest = readFileSync("src/gateway/gateway-codex-harness.live.test.ts", "utf8");
const liveDockerAuth = readFileSync("scripts/lib/live-docker-auth.sh", "utf8");
const sharedLiveScripts = [
readFileSync("scripts/test-live-models-docker.sh", "utf8"),
@@ -1176,6 +1190,28 @@ describe("package artifact reuse", () => {
expect(workflow).toContain(
'command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 35m bash .release-harness/scripts/test-live-codex-harness-docker.sh',
);
+ for (const [model, thinking] of [
+ ["sol", "ultra"],
+ ["terra", "ultra"],
+ ["luna", "max"],
+ ]) {
+ expect(workflow).toContain(
+ `OPENCLAW_LIVE_CODEX_HARNESS_TARGETS=openai/gpt-5.6-${model}=${thinking}`,
+ );
+ }
+ expect(workflow.match(/live-codex-harness\*-docker\)/gu)).toHaveLength(2);
+ for (const suiteId of [
+ "native-live-src-gateway-profiles-openai-api-default",
+ "native-live-src-gateway-profiles-openai-gpt56-ultra",
+ "live-codex-harness-gpt56-sol-docker",
+ "live-codex-harness-gpt56-terra-docker",
+ "live-codex-harness-gpt56-luna-docker",
+ "live-codex-harness-gpt56-docker",
+ ]) {
+ expect(workflow).toContain(`add_profile_suite ${suiteId} "stable full"`);
+ }
+ expect(codexLiveTest).toContain("command: `/model ${modelKey} --runtime codex`");
+ expect(codexLiveTest).toContain("thinkingLevel: CODEX_HARNESS_THINKING");
expect(workflow).toContain(
'command: OPENCLAW_LIVE_DOCKER_REPO_ROOT="$GITHUB_WORKSPACE" timeout --foreground --kill-after=30s 20m bash .release-harness/scripts/test-live-subagent-announce-docker.sh',
);
@@ -1316,7 +1352,7 @@ describe("package artifact reuse", () => {
"run_setup_command bash -lc 'curl -fsSL https://app.factory.ai/cli | sh'",
);
expect(readFileSync("scripts/test-live-codex-harness-docker.sh", "utf8")).toContain(
- "OPENCLAW_LIVE_CODEX_HARNESS_DOCKER_RUN_TIMEOUT:-2100s",
+ "OPENCLAW_LIVE_CODEX_HARNESS_DOCKER_RUN_TIMEOUT:-$((2100 * CODEX_HARNESS_TARGET_COUNT))s",
);
expect(readFileSync("scripts/test-live-codex-harness-docker.sh", "utf8")).toContain(
'CODEX_HARNESS_SETUP_TIMEOUT_SECONDS="$(openclaw_live_read_positive_int_env OPENCLAW_LIVE_CODEX_HARNESS_SETUP_TIMEOUT_SECONDS 180)"',
diff --git a/ui/src/api/types.ts b/ui/src/api/types.ts
index 35d29849c8b4..d76179f6c0e4 100644
--- a/ui/src/api/types.ts
+++ b/ui/src/api/types.ts
@@ -312,6 +312,7 @@ export type GatewaySessionsDefaults = {
modelProvider: string | null;
model: string | null;
contextTokens: number | null;
+ agentRuntime?: GatewayAgentRuntime;
thinkingLevels?: GatewayThinkingLevelOption[];
thinkingOptions?: string[];
thinkingDefault?: string;
@@ -568,6 +569,8 @@ export type SessionsPatchResult = SessionsPatchResultBase<{
modelProvider?: string;
model?: string;
agentRuntime?: GatewayAgentRuntime;
+ thinkingLevel?: string;
+ thinkingLevels?: GatewayThinkingLevelOption[];
};
};
diff --git a/ui/src/e2e/chat-flow.e2e.test.ts b/ui/src/e2e/chat-flow.e2e.test.ts
index a54be709104f..df70b568af1c 100644
--- a/ui/src/e2e/chat-flow.e2e.test.ts
+++ b/ui/src/e2e/chat-flow.e2e.test.ts
@@ -1993,6 +1993,35 @@ describeControlUiE2e("Control UI mocked Gateway E2E", () => {
}
});
+ it("routes runtime-aware model commands through the server directive path", async () => {
+ const context = await newBrowserContext({
+ locale: "en-US",
+ serviceWorkers: "block",
+ viewport: { height: 900, width: 1280 },
+ });
+ const page = await context.newPage();
+ const gateway = await installMockGateway(page, {
+ sessionKey: "agent:main:main",
+ });
+
+ try {
+ await page.goto(`${server.baseUrl}chat`);
+
+ const command = "/model openai/gpt-5.6-luna --runtime codex continue with the selected model";
+ await page.locator(".agent-chat__composer-combobox textarea").fill(command);
+ await page.getByRole("button", { name: "Send message" }).click();
+
+ const sendRequest = await gateway.waitForRequest("chat.send");
+ expect(requireRecord(sendRequest.params)).toMatchObject({
+ message: command,
+ sessionKey: "agent:main:main",
+ });
+ expect(await gateway.getRequests("sessions.patch")).toHaveLength(0);
+ } finally {
+ await closeBrowserContext(context);
+ }
+ });
+
it("keeps a session model override selected after switching away and back", async () => {
const context = await newBrowserContext({
locale: "en-US",
diff --git a/ui/src/lib/chat/thinking.test.ts b/ui/src/lib/chat/thinking.test.ts
new file mode 100644
index 000000000000..40c9f378dc56
--- /dev/null
+++ b/ui/src/lib/chat/thinking.test.ts
@@ -0,0 +1,91 @@
+// Control UI tests cover canonical and legacy thinking-level normalization.
+import { describe, expect, it } from "vitest";
+import {
+ formatThinkingOverrideLabel,
+ normalizeThinkLevel,
+ resolveChatThinkingSelectState,
+ resolveThinkingLevelInput,
+} from "./thinking.ts";
+
+describe("chat thinking helpers", () => {
+ it("keeps literal Ultra distinct from the legacy ultrathink alias", () => {
+ expect(normalizeThinkLevel("ultra")).toBe("ultra");
+ expect(normalizeThinkLevel("Ultra")).toBe("ultra");
+ expect(normalizeThinkLevel("ultrathink")).toBe("high");
+ expect(formatThinkingOverrideLabel("ultra")).toBe("Ultra");
+ });
+
+ it("accepts Ultra when the gateway advertises it for the session", () => {
+ expect(
+ resolveThinkingLevelInput(
+ "ultra",
+ {
+ key: "agent:main:main",
+ kind: "direct",
+ updatedAt: 1,
+ thinkingLevels: [{ id: "ultra", label: "Ultra" }],
+ },
+ undefined,
+ ),
+ ).toBe("ultra");
+ });
+
+ it("does not promote an unsupported persisted Ultra override into a slider stop", () => {
+ const state = resolveChatThinkingSelectState({
+ catalog: [],
+ sessionKey: "agent:main:main",
+ sessionsResult: {
+ ts: 1,
+ path: "",
+ count: 1,
+ defaults: { modelProvider: null, model: null, contextTokens: null },
+ sessions: [
+ {
+ key: "agent:main:main",
+ kind: "direct",
+ updatedAt: 1,
+ thinkingLevel: "ultra",
+ thinkingLevels: [{ id: "max", label: "max" }],
+ },
+ ],
+ },
+ });
+
+ expect(state.currentOverride).toBe("ultra");
+ expect(state.options.map((option) => option.value)).toEqual(["max"]);
+ });
+
+ it("does not inherit same-model thinking levels from a different runtime", () => {
+ const state = resolveChatThinkingSelectState({
+ catalog: [],
+ sessionKey: "agent:main:main",
+ sessionsResult: {
+ ts: 1,
+ path: "",
+ count: 1,
+ defaults: {
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ contextTokens: null,
+ agentRuntime: { id: "openclaw", source: "model" },
+ thinkingLevels: [
+ { id: "max", label: "max" },
+ { id: "ultra", label: "ultra" },
+ ],
+ },
+ sessions: [
+ {
+ key: "agent:main:main",
+ kind: "direct",
+ updatedAt: 1,
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "codex", source: "session-key" },
+ },
+ ],
+ },
+ });
+
+ expect(state.options.map((option) => option.value)).not.toContain("ultra");
+ });
+});
diff --git a/ui/src/lib/chat/thinking.ts b/ui/src/lib/chat/thinking.ts
index b82e64451eeb..fbae7dec593f 100644
--- a/ui/src/lib/chat/thinking.ts
+++ b/ui/src/lib/chat/thinking.ts
@@ -29,6 +29,9 @@ export function normalizeThinkLevel(raw?: string | null): string | undefined {
if (collapsed === "max") {
return "max";
}
+ if (collapsed === "ultra") {
+ return "ultra";
+ }
if (collapsed === "xhigh" || collapsed === "extrahigh") {
return "xhigh";
}
@@ -47,7 +50,7 @@ export function normalizeThinkLevel(raw?: string | null): string | undefined {
if (["mid", "med", "medium", "thinkharder", "think-harder", "harder"].includes(key)) {
return "medium";
}
- if (["high", "ultra", "ultrathink", "think-hard", "thinkhardest", "highest"].includes(key)) {
+ if (["high", "ultrathink", "think-hard", "thinkhardest", "highest"].includes(key)) {
return "high";
}
if (key === "think") {
@@ -165,7 +168,6 @@ export function resolveCurrentThinkingLevel(
function buildThinkingOptions(
levels: readonly GatewayThinkingLevelOption[],
- currentOverride: string,
): Array<{ value: string; label: string }> {
const seen = new Set();
const options: Array<{ value: string; label: string }> = [];
@@ -179,9 +181,6 @@ function buildThinkingOptions(
for (const level of levels) {
addOption(level.id, level.label);
}
- if (currentOverride) {
- addOption(currentOverride);
- }
return options;
}
@@ -294,7 +293,7 @@ export function resolveChatThinkingSelectState(params: {
currentOverride: effectiveOverride,
defaultLabel: formatInheritedThinkingLabel(defaultLevel),
defaultValue: normalizeThinkingOptionValue(defaultLevel),
- options: buildThinkingOptions(levels, effectiveOverride),
+ options: buildThinkingOptions(levels),
};
}
@@ -336,6 +335,8 @@ function formatThinkingLevelDisplayLabel(value: string): string {
return "Extra high";
case "max":
return "Maximum";
+ case "ultra":
+ return "Ultra";
default:
return value.charAt(0).toUpperCase() + value.slice(1);
}
diff --git a/ui/src/lib/session-model-defaults.ts b/ui/src/lib/session-model-defaults.ts
index dacc42be330a..c029bc8945e7 100644
--- a/ui/src/lib/session-model-defaults.ts
+++ b/ui/src/lib/session-model-defaults.ts
@@ -1,14 +1,17 @@
// Shared helpers for comparing session rows against list defaults.
import type { GatewaySessionRow, SessionsListResult } from "../api/types.ts";
-type SessionModelFields = Pick;
+type SessionModelFields = Pick;
export function sessionModelMatchesDefaults(
session: SessionModelFields | null | undefined,
defaults: SessionsListResult["defaults"] | undefined,
): boolean {
+ const sessionRuntime = session?.agentRuntime?.id?.trim();
+ const defaultRuntime = defaults?.agentRuntime?.id?.trim();
return (
(!session?.modelProvider || session.modelProvider === defaults?.modelProvider) &&
- (!session?.model || session.model === defaults?.model)
+ (!session?.model || session.model === defaults?.model) &&
+ (!sessionRuntime || !defaultRuntime || sessionRuntime === defaultRuntime)
);
}
diff --git a/ui/src/lib/sessions/reconcile.test.ts b/ui/src/lib/sessions/reconcile.test.ts
index 4db36da16439..55d046a4b495 100644
--- a/ui/src/lib/sessions/reconcile.test.ts
+++ b/ui/src/lib/sessions/reconcile.test.ts
@@ -1,6 +1,6 @@
import { describe, expect, it, test } from "vitest";
import type { SessionsListResult } from "../../api/types.ts";
-import { reconcileSessionChanged } from "./reconcile.ts";
+import { reconcileSessionChanged, reconcileSessionHistory } from "./reconcile.ts";
function buildResult(sessions: SessionsListResult["sessions"]): SessionsListResult {
return {
@@ -74,4 +74,202 @@ describe("reconcileSessionChanged", () => {
expect(next.applied).toBe(true);
expect(next.row?.category).toBe("Research");
});
+
+ it("replaces thinking metadata when the same model changes runtime", () => {
+ const key = "agent:main:main";
+ const result = buildResult([
+ {
+ key,
+ kind: "global",
+ updatedAt: 1,
+ sessionId: "s1",
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "openclaw", source: "model" },
+ thinkingLevels: [
+ { id: "max", label: "max" },
+ { id: "ultra", label: "ultra" },
+ ],
+ thinkingOptions: ["max", "ultra"],
+ },
+ ]);
+ const next = reconcileSessionChanged(result, {
+ sessionKey: key,
+ key,
+ kind: "global",
+ updatedAt: 2,
+ sessionId: "s1",
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "codex", source: "session-key" },
+ thinkingLevels: [{ id: "max", label: "max" }],
+ thinkingOptions: ["max"],
+ });
+
+ expect(next.row?.agentRuntime?.id).toBe("codex");
+ expect(next.row?.thinkingLevels).toEqual([{ id: "max", label: "max" }]);
+ expect(next.row?.thinkingOptions).toEqual(["max"]);
+ });
+
+ it("drops stale picker metadata when a runtime-change event omits catalog fields", () => {
+ const key = "agent:main:main";
+ const result = buildResult([
+ {
+ key,
+ kind: "global",
+ updatedAt: 1,
+ sessionId: "s1",
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "openclaw", source: "model" },
+ thinkingLevels: [
+ { id: "max", label: "max" },
+ { id: "ultra", label: "ultra" },
+ ],
+ thinkingOptions: ["max", "ultra"],
+ thinkingDefault: "medium",
+ },
+ ]);
+
+ const next = reconcileSessionChanged(result, {
+ sessionKey: key,
+ key,
+ kind: "global",
+ updatedAt: 2,
+ sessionId: "s1",
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "codex", source: "session-key" },
+ });
+
+ expect(next.row?.agentRuntime?.id).toBe("codex");
+ expect(next.row?.thinkingLevels).toBeUndefined();
+ expect(next.row?.thinkingOptions).toBeUndefined();
+ expect(next.row?.thinkingDefault).toBeUndefined();
+ });
+
+ it("does not let stale chat history overwrite a newer runtime switch", () => {
+ const key = "agent:main:main";
+ const current = buildResult([
+ {
+ key,
+ kind: "global",
+ updatedAt: 3,
+ sessionId: "s1",
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "codex", source: "session-key" },
+ thinkingLevels: [{ id: "max", label: "max" }],
+ },
+ ]);
+
+ const next = reconcileSessionHistory(
+ current,
+ {
+ key,
+ kind: "global",
+ updatedAt: 2,
+ sessionId: "s1",
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ agentRuntime: { id: "openclaw", source: "session-key" },
+ thinkingLevels: [
+ { id: "max", label: "max" },
+ { id: "ultra", label: "ultra" },
+ ],
+ },
+ undefined,
+ );
+
+ expect(next).toBe(current);
+ });
+
+ it("replaces same-model defaults when their runtime changes", () => {
+ const key = "agent:main:main";
+ const result: SessionsListResult = {
+ ...buildResult([{ key, kind: "global", updatedAt: 1, sessionId: "s1" }]),
+ defaults: {
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ contextTokens: null,
+ agentRuntime: { id: "openclaw", source: "model" },
+ thinkingLevels: [
+ { id: "max", label: "max" },
+ { id: "ultra", label: "ultra" },
+ ],
+ },
+ };
+
+ const next = reconcileSessionHistory(
+ result,
+ { key, kind: "global", updatedAt: 1, sessionId: "s1" },
+ {
+ modelProvider: "openai",
+ model: "gpt-5.6-luna",
+ contextTokens: null,
+ agentRuntime: { id: "codex", source: "model" },
+ thinkingLevels: [{ id: "max", label: "max" }],
+ },
+ );
+
+ expect(next?.defaults.agentRuntime?.id).toBe("codex");
+ expect(next?.defaults.thinkingLevels).toEqual([{ id: "max", label: "max" }]);
+ });
+
+ it("preserves catalog-backed options when an event omits picker metadata", () => {
+ const key = "agent:main:main";
+ const thinkingLevels = [
+ { id: "max", label: "max" },
+ { id: "ultra", label: "ultra" },
+ ];
+ const result = buildResult([
+ {
+ key,
+ kind: "global",
+ updatedAt: 1,
+ sessionId: "s1",
+ modelProvider: "openai",
+ model: "gpt-5.6-sol",
+ agentRuntime: { id: "codex", source: "model" },
+ thinkingLevels,
+ thinkingOptions: ["max", "ultra"],
+ },
+ ]);
+ const next = reconcileSessionChanged(result, {
+ sessionKey: key,
+ key,
+ kind: "global",
+ updatedAt: 2,
+ sessionId: "s1",
+ thinkingLevel: "ultra",
+ agentRuntime: { id: "codex", source: "model" },
+ });
+
+ expect(next.row?.thinkingLevel).toBe("ultra");
+ expect(next.row?.thinkingLevels).toEqual(thinkingLevels);
+ expect(next.row?.thinkingOptions).toEqual(["max", "ultra"]);
+ });
+
+ it("clears a thinking override when the event carries null", () => {
+ const key = "agent:main:main";
+ const result = buildResult([
+ {
+ key,
+ kind: "global",
+ updatedAt: 1,
+ sessionId: "s1",
+ thinkingLevel: "ultra",
+ },
+ ]);
+ const next = reconcileSessionChanged(result, {
+ sessionKey: key,
+ key,
+ kind: "global",
+ updatedAt: 2,
+ sessionId: "s1",
+ thinkingLevel: null,
+ });
+
+ expect(next.row?.thinkingLevel).toBeUndefined();
+ });
});
diff --git a/ui/src/lib/sessions/reconcile.ts b/ui/src/lib/sessions/reconcile.ts
index cbe93f49da25..661654fb040b 100644
--- a/ui/src/lib/sessions/reconcile.ts
+++ b/ui/src/lib/sessions/reconcile.ts
@@ -42,6 +42,7 @@ type SessionChangedEventInfo = {
type ThinkingMetadataCarrier = {
modelProvider?: string | null;
model?: string | null;
+ agentRuntime?: { id: string } | null;
thinkingLevels?: Array<{ id: string; label: string }>;
thinkingOptions?: string[];
thinkingDefault?: string;
@@ -66,15 +67,19 @@ function isPersistedSessionRow(row: GatewaySessionRow): boolean {
return Boolean(sessionId || typeof row.updatedAt === "number");
}
-function thinkingMetadataModelMatches(
+function thinkingMetadataIdentityMatches(
incoming: ThinkingMetadataCarrier,
existing: ThinkingMetadataCarrier,
): boolean {
+ const incomingRuntime = incoming.agentRuntime?.id?.trim();
+ const existingRuntime = existing.agentRuntime?.id?.trim();
+ // Provider profiles can differ by runtime for the same model (for example Luna Ultra).
return !(
(incoming.modelProvider &&
existing.modelProvider &&
incoming.modelProvider !== existing.modelProvider) ||
- (incoming.model && existing.model && incoming.model !== existing.model)
+ (incoming.model && existing.model && incoming.model !== existing.model) ||
+ (incomingRuntime && existingRuntime && incomingRuntime !== existingRuntime)
);
}
@@ -82,7 +87,7 @@ function preserveRicherThinkingMetadata(
incoming: T,
existing: ThinkingMetadataCarrier | undefined,
): T {
- if (existing && !thinkingMetadataModelMatches(incoming, existing)) {
+ if (existing && !thinkingMetadataIdentityMatches(incoming, existing)) {
return incoming;
}
const existingLevels = existing?.thinkingLevels;
@@ -99,6 +104,25 @@ function preserveRicherThinkingMetadata(
};
}
+function stripThinkingMetadata(value: T): T {
+ const next = { ...value };
+ delete next.thinkingLevels;
+ delete next.thinkingOptions;
+ delete next.thinkingDefault;
+ return next;
+}
+
+function isOlderSessionSnapshot(
+ incoming: GatewaySessionRow,
+ existing: GatewaySessionRow | undefined,
+): boolean {
+ return (
+ typeof incoming.updatedAt === "number" &&
+ typeof existing?.updatedAt === "number" &&
+ incoming.updatedAt < existing.updatedAt
+ );
+}
+
function isStaleForActiveSession(
incoming: GatewaySessionRow,
existing: GatewaySessionRow | undefined,
@@ -319,8 +343,18 @@ export function reconcileSessionChanged(
if (!kind || (!existing && sessionId === undefined && typeof updatedAt !== "number")) {
return { applied: false, result };
}
+ const incomingRuntime = recordOrNull(rowFields.agentRuntime);
+ const incomingThinkingIdentity: ThinkingMetadataCarrier = {
+ modelProvider: stringValue(rowFields.modelProvider),
+ model: stringValue(rowFields.model),
+ ...(incomingRuntime ? { agentRuntime: { id: stringValue(incomingRuntime.id) ?? "" } } : {}),
+ };
+ const existingFields =
+ existing && !thinkingMetadataIdentityMatches(incomingThinkingIdentity, existing)
+ ? stripThinkingMetadata(existing)
+ : existing;
const row = {
- ...existing,
+ ...existingFields,
...rowFields,
key: existing?.key ?? key,
kind,
@@ -342,6 +376,9 @@ export function reconcileSessionChanged(
if (rowFields.displayName === null) {
delete row.displayName;
}
+ if (rowFields.thinkingLevel === null) {
+ delete row.thinkingLevel;
+ }
const next = reconcileSessionHistory(result, row, undefined, {
...options,
selectedGlobalAgentId,
@@ -416,6 +453,9 @@ export function reconcileSessionHistory(
const existing = result.sessions.find((candidate) =>
matchesExistingSession(candidate, session, selectedGlobalAgentId),
);
+ if (isOlderSessionSnapshot(session, existing)) {
+ return result;
+ }
const nextDefaults = defaults
? preserveRicherThinkingMetadata(defaults, result.defaults)
: result.defaults;
diff --git a/ui/src/pages/chat/chat-send.ts b/ui/src/pages/chat/chat-send.ts
index 4171436de286..ded590406127 100644
--- a/ui/src/pages/chat/chat-send.ts
+++ b/ui/src/pages/chat/chat-send.ts
@@ -1,4 +1,5 @@
// Control UI module implements app chat behavior.
+import { shouldForwardModelCommandToServer } from "../../../../src/auto-reply/commands-registry.shared.js";
import { isNonTerminalAgentRunStatus } from "../../../../src/shared/agent-run-status.js";
import {
GatewayRequestError,
@@ -1263,7 +1264,9 @@ export async function handleSendChat(
}
// Intercept local slash commands (/status, /model, /compact, etc.)
- if (parsed?.command.executeLocal) {
+ const forwardModelCommand =
+ parsed?.command.key === "model" && shouldForwardModelCommandToServer(parsed.args);
+ if (parsed?.command.executeLocal && !forwardModelCommand) {
if (isChatBusy(host) && shouldQueueLocalSlashCommand(parsed.command.key)) {
if (messageOverride == null) {
recordNonTranscriptInputHistory(host, message);