feat: support GPT-5.6 Ultra across OpenClaw and Codex runtimes (#98021)

* feat: support GPT-5.6 Ultra across agent runtimes

Co-authored-by: J Cai <anyech@gmail.com>

* fix: keep harness projections discovery-free

* fix(codex): mirror V2 native subagent tasks

* chore: refresh plugin SDK surface budgets

* test: expose Ultra wire effort proof

* test(cron): avoid hoisted mock initialization race

---------

Co-authored-by: Peter Steinberger <steipete@gmail.com>
This commit is contained in:
JC
2026-07-10 07:23:24 -07:00
committed by GitHub
parent 8034454568
commit 59e95fe3fd
175 changed files with 7658 additions and 1193 deletions
@@ -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"
+1
View File
@@ -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.
+1 -1
View File
@@ -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`.
</ParamField>
<ParamField path="--thinking" type="string">
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.
</ParamField>
<ParamField path="--clear-thinking" type="boolean">
On `cron edit`, removes the per-job thinking override. Cannot combine with `--thinking`.
+46 -6
View File
@@ -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 <ref> --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/<model>`
- Thinking override: `OPENCLAW_LIVE_CODEX_HARNESS_THINKING=<level>`
- Matrix override: `OPENCLAW_LIVE_CODEX_HARNESS_TARGETS=<model>=<thinking>,...`
- 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
+7 -4
View File
@@ -8,7 +8,7 @@ title: "Thinking levels"
## What it does
- Inline directive in any inbound body: `/t <level>`, `/think:<level>`, or `/thinking <level>`.
- 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.<provider>.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 `<think>
- 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.
+86 -4
View File
@@ -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) => {
+53 -21
View File
@@ -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<string, CodexReasoningEffort>([
["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);
}
@@ -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 },
@@ -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([
@@ -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(
@@ -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") {
+10 -4
View File
@@ -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;
@@ -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");
@@ -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");
});
});
@@ -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<typeof startOrResumeThread>[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 {
@@ -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;
@@ -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");
@@ -64,7 +64,7 @@ export function buildDiscordCommandOptions(params: {
authorizeChoiceContext?: (interaction: AutocompleteInteraction) => Promise<boolean>;
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
@@ -137,6 +137,7 @@ let resolveDiscordNativeChoiceContext: typeof import("./native-command-model-pic
async function saveSessionOverride(params: {
providerOverride: string;
modelOverride: string;
agentRuntimeOverride?: string;
}): Promise<void> {
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<string, SessionEntry>,
{ 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);
@@ -497,6 +497,7 @@ async function dispatchDiscordCommandInteraction(params: {
cfg,
provider: menuModelContext?.provider,
model: menuModelContext?.model,
agentRuntime: menuModelContext?.agentRuntime,
...(menuModelCatalog?.length ? { catalog: menuModelCatalog } : {}),
});
if (menu) {
+98 -11
View File
@@ -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 () => {
+13 -1
View File
@@ -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<OpenClawPluginApi["runtime"]["agent"]["normalizeThinkingLevel"]> =
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,
+2 -1
View File
@@ -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)) {
+128 -3
View File
@@ -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();
+20 -13
View File
@@ -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<string, unknown>;
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) => {
@@ -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");
});
});
+7 -2
View File
@@ -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;
}
+107 -13
View File
@@ -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<OpenAIThinkingLevelId>(["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,
});
}
+75 -2
View File
@@ -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<typeof import("openclaw/plugin-sdk/agent-runtime")>(
"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<T>() {
return { promise, resolve };
}
function createArgMenusHarness() {
function createArgMenusHarness(
cfg: OpenClawConfig = { commands: { native: true, nativeSkills: false } },
) {
const commands = new Map<string, (args: unknown) => Promise<void>>();
const actions = new Map<string | RegExp, (args: unknown) => Promise<void>>();
const options = new Map<string, (args: unknown) => Promise<void>>();
@@ -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<string, (args: unknown) => Promise<void>>();
const actions = new Map<string | RegExp, (args: unknown) => Promise<void>>();
+28 -19
View File
@@ -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 {};
@@ -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/*": {} } } },
+50 -35
View File
@@ -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<ReturnType<typeof loadModelCatalog>>;
}): Promise<string> {
@@ -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,
});
}
+1 -1
View File
@@ -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"
+2 -2
View File
@@ -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);
});
+1
View File
@@ -299,6 +299,7 @@ const VoiceCallRealtimeConsultThinkingLevelSchema = z.enum([
"xhigh",
"adaptive",
"max",
"ultra",
]);
const VoiceCallStreamingProvidersConfigSchema = z
+2 -2
View File
@@ -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<string, unknown> = {};
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);
});
});
+2
View File
@@ -1829,6 +1829,8 @@ type SessionsPatchResult = {
modelProvider?: string;
model?: string;
agentRuntime?: GatewayAgentRuntime;
thinkingLevel?: string;
thinkingLevels?: Array<{ id: string; label: string }>;
};
};
+6 -6
View File
@@ -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(
+39 -2
View File
@@ -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" \
+10
View File
@@ -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");
});
});
+7 -2
View File
@@ -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)." },
@@ -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<unknown>;
onFallbackStep?: (step: Record<string, unknown>) => void | Promise<void>;
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<string, SessionEntry>)["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"));
+127 -31
View File
@@ -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,
});
}
+18
View File
@@ -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();
+8 -1
View File
@@ -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,
@@ -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<typeof runAgentAttempt>[0]["opts"],
runContext: {} as Parameters<typeof runAgentAttempt>[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",
+27 -14
View File
@@ -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);
@@ -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: [] },
+20 -4
View File
@@ -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();
@@ -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.
@@ -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<TPayload extends Record<string, unknown>> = {
headers?: Record<string, string>;
@@ -37,7 +37,7 @@ type RunExtraParamsCaseParams<
mockProviderRuntime?: boolean;
options?: SimpleStreamOptions;
payload: TPayload;
thinkingLevel?: ThinkLevel;
thinkingLevel?: ProviderThinkLevel;
};
export function runExtraParamsCase<
@@ -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<string, unknown>;
thinkingLevel?: ThinkLevel;
thinkingLevel?: ProviderThinkLevel;
agentId?: string;
resolvedExtraParams?: Record<string, unknown>;
model?: ProviderRuntimeModel;
@@ -239,7 +239,7 @@ export function resolvePreparedExtraParams(params: {
agentDir?: string;
workspaceDir?: string;
extraParamsOverride?: Record<string, unknown>;
thinkingLevel?: ThinkLevel;
thinkingLevel?: ProviderThinkLevel;
agentId?: string;
resolvedExtraParams?: Record<string, unknown>;
model?: ProviderRuntimeModel;
@@ -786,7 +786,7 @@ type ApplyExtraParamsContext = {
modelId: string;
agentDir?: string;
workspaceDir?: string;
thinkingLevel?: ThinkLevel;
thinkingLevel?: ProviderThinkLevel;
model?: ProviderRuntimeModel;
effectiveExtraParams: Record<string, unknown>;
resolvedExtraParams?: Record<string, unknown>;
@@ -1064,7 +1064,7 @@ export function applyExtraParamsToAgent(
provider: string,
modelId: string,
extraParamsOverride?: Record<string, unknown>,
thinkingLevel?: ThinkLevel,
thinkingLevel?: ProviderThinkLevel,
agentId?: string,
workspaceDir?: string,
model?: ProviderRuntimeModel,
@@ -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<typeof import("./utils.js")>("./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 };
@@ -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 () => {
+31 -2
View File
@@ -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<ThinkLevel>();
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,
});
@@ -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<typeof import("../extra-params.js")>("../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<typeof import("../utils.js")>("../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();
@@ -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<EmbeddedRunAttemptResult> {
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,
@@ -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");
});
});
@@ -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.
@@ -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,
+10 -1
View File
@@ -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");
});
});
+11 -3
View File
@@ -4,23 +4,31 @@
import type { ReasoningLevel, ThinkLevel } from "../../auto-reply/thinking.js";
import type { ThinkingLevel } from "../runtime/index.js";
export type ProviderThinkLevel = Exclude<ThinkLevel, "ultra">;
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 };
@@ -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);
});
});
+10 -63
View File
@@ -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,
+86
View File
@@ -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;
}
+3
View File
@@ -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;
}
+52
View File
@@ -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.
+15 -2
View File
@@ -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,
},
+52
View File
@@ -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({
+38 -9
View File
@@ -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<T>(
// 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,
+1 -9
View File
@@ -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,
+7 -3
View File
@@ -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<ModelCatalogEntry[]>;
agentRuntime?: string | null;
}): Promise<ThinkLevel> {
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,
});
}
+1 -6
View File
@@ -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,
+1 -1
View File
@@ -21,7 +21,7 @@ type Assert<T extends true> = T;
describe("AgentRuntimePlan structural type compatibility", () => {
it("keeps copied scalar unions aligned with their source contracts", () => {
expectTypeOf<AgentRuntimeThinkLevel>().toEqualTypeOf<ThinkLevel>();
expectTypeOf<AgentRuntimeThinkLevel>().toEqualTypeOf<Exclude<ThinkLevel, "ultra">>();
expectTypeOf<AgentRuntimeFailoverReason>().toEqualTypeOf<FailoverReason>();
expectTypeOf<AgentRuntimePromptMode>().toEqualTypeOf<PromptMode>();
});
+26 -4
View File
@@ -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<SessionEntry, "agentHarnessId" | "agentRuntimeOverride">;
type SessionRuntimeOverrideEntry = Pick<SessionEntry, "agentRuntimeOverride">;
/** 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;
}
+79 -77
View File
@@ -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 = {
+1
View File
@@ -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"
+43
View File
@@ -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<typeof buildAgentSystemPrompt>[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",
+26 -1
View File
@@ -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<string, StablePromptPrefixCacheEntry>();
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"),
+199
View File
@@ -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<typeof listRegisteredAgentHarnesses>;
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<AgentHarness["supports"]>(({ 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");
});
});
+92
View File
@@ -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<SessionEntry, "agentHarnessId" | "agentRuntimeOverride">;
}): 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<SessionEntry, "agentHarnessId" | "agentRuntimeOverride">;
/** 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);
}
+16 -4
View File
@@ -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",
+33 -2
View File
@@ -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",
+6 -1
View File
@@ -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) {
@@ -33,6 +33,7 @@ export type CommandArgChoiceContext = {
cfg?: OpenClawConfig;
provider?: string;
model?: string;
agentRuntime?: string;
catalog?: ThinkingCatalogEntry[];
command: ChatCommandDefinition;
arg: CommandArgDefinition;
@@ -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<unknown> }) => ({
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<unknown> }) => {
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 () => {
+21 -29
View File
@@ -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<SessionEntry, "agentRuntimeOverride">;
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<SessionEntry, "agentRuntimeOverride"> | 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,
@@ -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<unknown> }) => {
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({
+23 -33
View File
@@ -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<SessionEntry, "agentRuntimeOverride">;
}): 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,
+74 -2
View File
@@ -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");
});
});
+66 -12
View File
@@ -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.");
}
@@ -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();
@@ -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<SessionEntry, "agentRuntimeOverride">;
}): 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<SessionEntry, "agentRuntimeOverride">,
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 };
}
@@ -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([
{
@@ -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<SessionEntry, "agentHarnessId" | "agentRuntimeOverride">;
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,
+160 -144
View File
@@ -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,
+1 -1
View File
@@ -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,
@@ -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<unknown> }) => {
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<typeof import("../../infra/agent-events.js")>(
"../../infra/agent-events.js",
+13 -3
View File
@@ -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,
@@ -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();
});
});

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