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