Skip to content

Commit 0d5c876

Browse files
yimojjyaunches
andauthored
fix(rebuild): keep compatible-endpoint reasoning through recovered recreate (#7940) (#7991)
## Summary A `rebuild` that has to recover the recorded provider from the registered gateway credential replaced the sandbox with no reasoning configuration: the replacement recorded `compatibleEndpointReasoningEffort = null` even though the authoritative resume configuration carried `high`, so the effort had to be restored by hand with `inference set --reasoning-effort high`. Three NemoClaw-owned gaps combined, and all three are fixed here. The documented contract in `docs/inference/configure-model-capabilities.mdx` — "a resumed onboard and `nemoclaw <sandbox-name> rebuild` both replay those recorded values" — now holds on the recovered path too. No new flag, environment variable, or configuration surface: the reporter's command is `rebuild --yes`, and it works unchanged. ## Related Issue Fixes #7940 ## Changes - `src/lib/onboard/session-updates.ts`: `OnboardSessionUpdateInput` and `toSessionUpdates()` never carried `compatibleEndpointReasoningEffort`, so the value the callers in `machine/handlers/provider-inference.ts` supply was dropped before the session was written — and therefore before the registry entry that `onboard/sandbox-registration.ts` writes from that session. `SessionUpdates` already supported the field. The normalizer now maps it, keeping the `undefined` leave-unchanged versus explicit `null` clear semantics, and normalizing through `normalizeReasoningEffort` so an unrecognized value clears the recorded effort instead of persisting an effort the endpoint never received. This gap alone also made a *fresh* compatible-endpoint onboard with `NEMOCLAW_REASONING_EFFORT=high` record `null`. - `src/lib/actions/sandbox/rebuild-recreate-phase.ts`: the recreate isolates the ambient onboard-selection environment so an unrelated onboard cannot steer it (#5735), but it re-seeded only `NEMOCLAW_POLICY_TIER`. Both the recovered provider selection and `src/lib/onboard/dockerfile-patch.ts` — which bakes `ARG NEMOCLAW_REASONING_EFFORT` into the sandbox image — read the value from `process.env`, so the inner `onboard --resume` ran with no reasoning inputs. The recreate now re-seeds `NEMOCLAW_REASONING` and `NEMOCLAW_REASONING_EFFORT` from the authoritative `resumeConfig` inside the isolated window, only for the `compatible-endpoint` provider that can use them. The existing isolation restore returns the caller's ambient values on success and on failure, so the seed stays scoped to the recreate. - `src/lib/onboard/machine/handlers/provider-inference.ts`: the recovered branch reaches `resolveRecoveredProviderCredentialReuse()` (no host credential copy, validated gateway credential) and sets `reuseGatewayCredentialWithoutLocalKey`, which short-circuits `validateSelectedRemoteModel()` — the only caller of `configureCompatibleEndpointReasoning[Effort]()` for a custom endpoint. The selection therefore reported no reasoning at all and cleared the recorded values. The handler now replays the recorded mode and effort for that same route when the selection reused the gateway credential. This is the boundary that owns the recorded route, so the fix also covers a plain recovered `onboard --resume`, not only a rebuild recreate. - `src/lib/actions/sandbox/rebuild-recreate-reasoning.test.ts` (new): the recreate observes the recorded values, ambient values are restored on success and on a failed recreate, a resume configuration that no longer carries an effort clears it for the recreate, and a provider that cannot use reasoning still sees the isolated environment (#5735 unchanged). - `src/lib/onboard/machine/handlers/provider-inference-authoritative-recovery.test.ts`, `src/lib/onboard/session-updates.test.ts`, `test/helpers/rebuild-flow-lifecycle-cases.ts`: regression coverage for the recovered gateway-credential replay, the normalizer's nullable contract, and the rebuild lifecycle case that now asserts the effort survives. - Follow-up commit `1b1bf3e39` (CodeRabbit finding): the replay applied the recorded values without naming them, so an exported `NEMOCLAW_REASONING`/`NEMOCLAW_REASONING_EFFORT` that disagreed with the recorded value was discarded silently — the exact no-op #7462 removed from the sibling resumed-selection path. The replay now emits the same `describeIgnoredReasoningEnv()` / `describeIgnoredReasoningEffortEnv()` report before configuring, extracted into `replayRecoveredCompatibleEndpointReasoning()` to keep `handleProviderInferenceState` under the Biome cognitive-complexity budget. A rebuild recreate seeds the environment from the same recorded configuration, so it stays silent. Covered by a new case that fails without the source change, strengthened in `2b0610047` to assert that the report names the recorded values, not only the ignored ambient ones. Each new assertion was confirmed to fail without its corresponding source change. ## Type of Change - [x] Code change (feature, bug fix, or refactor) - [ ] Code change with doc updates - [ ] Doc only (prose changes, no code sample modifications) - [ ] Doc only (includes code sample changes) ## Quality Gates - [x] Tests added or updated for changed behavior - [ ] Existing tests cover changed behavior — justification: - [ ] Tests not applicable — justification: - [ ] Docs updated for user-facing behavior changes - [x] Docs not applicable — justification: no user-visible surface changes. `docs/inference/configure-model-capabilities.mdx` already documents the behavior this restores ("Onboarding records the reasoning flag and the reasoning effort in the sandbox's session and registry entry. A resumed onboard and `nemoclaw <sandbox-name> rebuild` both replay those recorded values"), and the `inference set --reasoning-effort` contract, the route rules, and the reasoning-effort inputs are all unchanged. - [x] Sensitive paths changed (security, policy, credentials, preflight, onboarding, inference, runner, sandbox, or messaging) - [x] Sensitive-path review completed or maintainer-approved waiver recorded — reviewer/approval link/justification: the credential path is untouched. The recovered branch still reuses the validated gateway credential and still skips `validateSelectedRemoteModel()`; this change only replays the already-recorded reasoning mode and effort for that selection, and no credential value is read, written, logged, or moved. The reseeded environment values are the two reasoning variables, taken from the authoritative resume configuration rather than the ambient environment, and they are restored by the existing `#5735` isolation restore on success and failure — so ambient-environment isolation is preserved, not weakened. `codex review --base origin/main` reports no findings at head `1b1bf3e39`. - [ ] Non-success, skipped, or missing CI check accepted by maintainer — check name, approval link, and follow-up issue: ## Documentation Writer Review - [x] Documentation writer subagent reviewed the completed changes - Result: `docs-accurate-no-change` - Evidence: reviewed the changed sources against the reasoning-effort documentation. `docs/inference/configure-model-capabilities.mdx` already states that a resumed onboard and `nemoclaw <sandbox-name> rebuild` replay the recorded reasoning flag and reasoning effort, and that an exported `NEMOCLAW_REASONING`/`NEMOCLAW_REASONING_EFFORT` does not change them; that is exactly the contract this change restores on the recovered path, so the page is accurate before and after. `docs/reference/commands.mdx` documents `inference set --reasoning-effort`, which is unchanged. No new flag, environment variable, default, error message, or command output is introduced, so no page needs an edit and no changelog surface changes. Review performed by the primary agent in this session; no separate subagent surface was used. Re-reviewed at head `1b1bf3e39` after the CodeRabbit follow-up: that commit adds an operator-facing report that reuses the existing `Ignoring NEMOCLAW_REASONING...` message already documented on the same page, so no page needs an edit. Re-checked at head `2b0610047`: that commit strengthens a test assertion only, so no documentation change is needed. The governing `AGENTS.md` blob remains `c052d60aa`. - Agent: Claude Code <!-- docs-review-head-sha: 2b06100 --> <!-- docs-review-agents-blob-sha: c052d60 --> ## Verification - [x] PR description includes a `Signed-off-by:` line and every commit appears as `Verified` in GitHub - [x] Normal `pre-commit`, `commit-msg`, and `pre-push` hooks passed, or `npm run validate:pr` passed after refreshing `origin/main` when hooks were skipped or unavailable — `npm run validate:pr` after `git fetch origin main` and a rebase onto `origin/main` `387cb0864`: all prek pre-commit hooks, commitlint, and pre-push hooks Passed (exit 0). commitlint reports 0 problems and 1 `footer-leading-blank` warning for the `Signed-off-by:` trailer that follows the `Fixes` line. - [x] Targeted behavior tests pass for the current change set, or tests are marked not applicable above — command/result or justification: `npx vitest run --project cli src/lib/onboard/session-updates.test.ts src/lib/actions/sandbox/rebuild-recreate-reasoning.test.ts src/lib/onboard/machine/handlers/provider-inference-authoritative-recovery.test.ts src/lib/actions/sandbox/rebuild-env-isolation.test.ts src/lib/actions/sandbox/rebuild-flow.test.ts src/lib/onboard/setup-nim-flow.test.ts` → 6 files / 136 tests passed; `npm run typecheck:cli` and `npm run checks:repository` clean - [x] Applicable broad gate passed — `npm test` for broad runtime/test-harness changes; `npm run check` for repo-wide validation/coverage changes — command/result: `npm test` at head `1b1bf3e39` → 23188 passed, 14 failed across 8 files, every one pre-existing or load-induced and unrelated to this change: - `test/install-station-dgx-os.test.ts` (5), `test/install-station-host-preparation.test.ts` (3), `test/install-station-package-transaction.test.ts` (1): this host has a pending reboot (`/var/run/reboot-required`, dated 2026-07-28), so `station-prepare` refuses with `A reboot is pending on the Station factory image` / `An unrelated reboot is already pending`. All 9 reproduce identically in a clean `origin/main` worktree. - `test/issue-5667-hosted-inference-model-namespace.test.ts` (1): reproduces identically in a clean `origin/main` worktree, in isolation. - `test/package-contract/managed-image-registry-transport.test.ts` (1): the packed-CLI `--omit=dev` install fails registry resolution with `npm error notarget No matching version found for @aws-sdk/core@^3.977.3`, an upstream dependency-resolution failure with no relation to this change. - `test/gateway-state-reconcile-2276.test.ts`, `test/rebuild-credential-preflight.test.ts`, `test/langchain-deepagents-code-image-credentials.test.ts` (1 each): 15-60 s timeouts under full-suite parallel load. Re-running exactly those three files on this branch passes 143/143 in 80 s. The first two exercise the rebuild lifecycle this change touches, so they were re-run specifically to confirm the change is not the cause. - [x] Quality Gates section completed with required justifications or waivers - [x] No secrets, API keys, or credentials committed - [ ] `npm run docs` builds without warnings (doc changes only) - [ ] Doc pages follow the [style guide](https://github.com/NVIDIA/NemoClaw/blob/main/docs/CONTRIBUTING.md) (doc changes only) - [ ] New doc pages include SPDX header and frontmatter (new pages only) ### Reporter-workflow E2E (Linux x86_64, real worktree CLI) Every command below ran the worktree CLI (`./bin/nemoclaw.js`), never a global `nemoclaw`. Gateway port 8194 with an isolated state root (`~/.nemoclaw/gateways/8194`), sandbox `e2e-7940-effort`, a local OpenAI-compatible mock endpoint on `http://127.0.0.1:8000/v1`, a configured Telegram channel, and presets `telegram,local-inference`. The reporter's step 3 is reproduced by leaving `COMPATIBLE_API_KEY` out of the rebuild environment while the validated gateway credential stays registered, which is what forces the recovered-provider branch. **Before the fix** (`dist` built from `origin/main`): ```text $ NEMOCLAW_REASONING=true NEMOCLAW_REASONING_EFFORT=high ./bin/nemoclaw.js onboard --fresh --non-interactive ... session.compatibleEndpointReasoningEffort = null # the normalizer dropped it registry.compatibleEndpointReasoningEffort = null generated openclaw.json "reasoning_effort" = "high" # baked from the ambient env $ ./bin/nemoclaw.js e2e-7940-effort inference set --provider compatible-endpoint --model ... --reasoning-effort high session.compatibleEndpointReasoning = true, effort = high # documented pre-rebuild state restored registry effort = high $ ./bin/nemoclaw.js e2e-7940-effort rebuild --yes # no COMPATIBLE_API_KEY in the environment Revalidating recovered compatible-endpoint identity… Reusing existing gateway credential… exit 0 session.compatibleEndpointReasoning = null # the recorded reasoning mode was lost ``` The recorded effort survived that run only because the same normalizer defect also dropped the recovered `null`; the reasoning mode, which the normalizer did carry, was cleared to `null` — the reporter's defect. **After the fix** (`dist` built from this branch, same commands): ```text $ NEMOCLAW_REASONING=true NEMOCLAW_REASONING_EFFORT=high ./bin/nemoclaw.js onboard --fresh --non-interactive ... session reasoning = true, effort = high registry reasoning = true, effort = high $ ./bin/nemoclaw.js e2e-7940-effort rebuild --yes # no COMPATIBLE_API_KEY, same recovered branch exit 0 session reasoning = true, effort = high registry reasoning = true, effort = high in-sandbox /sandbox/.openclaw/openclaw.json "reasoning_effort" = "high" image built by the recreate (nemoclaw-sandbox-local:e2e-7940-effort-1785467801916) has the same value baked in ``` All three recording surfaces the issue names — onboard session, replacement registry row, and generated OpenClaw configuration — keep `high` across the recovered rebuild. --- Signed-off-by: Yimo Jiang <yimoj@nvidia.com> <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit * **New Features** * Preserved compatible-endpoint reasoning mode and effort settings during onboarding recovery and sandbox recreation. * Added support for storing, restoring, and clearing reasoning effort settings in onboarding sessions. * Ensured recovered settings override conflicting ambient configuration when applicable. * **Bug Fixes** * Prevented reasoning configuration from being lost during sandbox rebuilds or credential-based recovery. * **Tests** * Added coverage for successful recovery, failure cleanup, stale-value removal, and provider-specific behavior. <!-- end of auto-generated comment: release notes by coderabbit.ai --> --------- Signed-off-by: Yimo Jiang <yimoj@nvidia.com> Co-authored-by: J. Yaunches <jyaunches@nvidia.com>
1 parent 7eb0369 commit 0d5c876

7 files changed

Lines changed: 452 additions & 2 deletions

File tree

src/lib/actions/sandbox/rebuild-recreate-phase.ts

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { CLI_NAME } from "../../cli/branding";
55
import { RD as _RD, R } from "../../cli/terminal-style";
66
import type { SandboxMessagingPlan } from "../../messaging";
77
import { markLastStartedStepFailed } from "../../onboard/exit-step-failure";
8+
import { applyReasoningEffortEnv } from "../../onboard/reasoning-mode";
89
import * as shields from "../../shields";
910
import type { Session } from "../../state/onboard-session";
1011
import * as onboardSession from "../../state/onboard-session";
@@ -193,6 +194,17 @@ export async function runRebuildRecreatePhase(input: RebuildRecreatePhaseInput):
193194
if (recreateOptions.policyTier) {
194195
process.env.NEMOCLAW_POLICY_TIER = recreateOptions.policyTier;
195196
}
197+
// Isolation removed the ambient reasoning inputs so an unrelated onboard
198+
// cannot steer this recreate (#5735). The recreate still has to reapply the
199+
// *recorded* compatible-endpoint reasoning configuration: both the recovered
200+
// provider selection and the sandbox image patch that bakes
201+
// ARG NEMOCLAW_REASONING_EFFORT read it from the process env, so without this
202+
// seed the replacement records no reasoning effort (#7940). The isolation
203+
// restore puts the caller's ambient values back on success and failure.
204+
if (resumeConfig.provider === "compatible-endpoint") {
205+
process.env.NEMOCLAW_REASONING = resumeConfig.compatibleEndpointReasoning ?? "false";
206+
applyReasoningEffortEnv(resumeConfig.compatibleEndpointReasoningEffort);
207+
}
196208
const restoreRebuildBaseImageOverride =
197209
pinRebuildAgentBaseImageForRecreate(rebuildBaseImagePreflight);
198210
try {
Lines changed: 208 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,208 @@
1+
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
2+
// SPDX-License-Identifier: Apache-2.0
3+
4+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
5+
6+
import { restoreEnv } from "../../../../test/helpers/env-test-helpers";
7+
import type { Session } from "../../state/onboard-session";
8+
import * as onboardSession from "../../state/onboard-session";
9+
import type { RebuildDurableConfig } from "./rebuild-durable-config";
10+
import type { RebuildRecreateOnboardOpts } from "./rebuild-gpu-opt-out";
11+
import { rebuildOnboardDependencies } from "./rebuild-onboard-dependencies";
12+
import { type RebuildRecreatePhaseInput, runRebuildRecreatePhase } from "./rebuild-recreate-phase";
13+
import type { RebuildResumeConfig } from "./rebuild-resume-config";
14+
15+
const SANDBOX_NAME = "rebuild-reasoning-effort";
16+
17+
const durableConfig: RebuildDurableConfig = {
18+
dcodeAutoApprovalMode: "disabled",
19+
dcodeAutoApprovalModeError: null,
20+
fromDockerfile: null,
21+
fromDockerfileError: null,
22+
hermesAuthMethod: null,
23+
hermesAuthMethodError: null,
24+
webSearchConfig: null,
25+
webSearchError: null,
26+
toolDisclosure: "progressive",
27+
toolDisclosureError: null,
28+
};
29+
30+
const compatibleResumeConfig: RebuildResumeConfig = {
31+
agent: "openclaw",
32+
provider: "compatible-endpoint",
33+
model: "mock/deepseek-compatible",
34+
nimContainer: null,
35+
credentialEnv: "COMPATIBLE_API_KEY",
36+
preferredInferenceApi: "openai-completions",
37+
compatibleEndpointReasoning: "true",
38+
compatibleEndpointReasoningEffort: "high",
39+
pinEndpoint: true,
40+
endpointUrl: "https://compatible.example.test/v1",
41+
registryInferenceRoute: null,
42+
ambient: { presentVars: [], agentMismatch: null },
43+
};
44+
45+
const recreateOptions: RebuildRecreateOnboardOpts = {
46+
resume: true,
47+
nonInteractive: true,
48+
recreateSandbox: true,
49+
authoritativeResumeConfig: true,
50+
acceptThirdPartySoftware: true,
51+
agent: "openclaw",
52+
fromDockerfile: null,
53+
sandboxGpu: null,
54+
sandboxGpuDevice: null,
55+
controlUiPort: null,
56+
targetGatewayName: "nemoclaw",
57+
targetGatewayPort: 8080,
58+
onboardLockAlreadyHeld: true,
59+
autoYes: true,
60+
toolDisclosure: "progressive",
61+
dcodeAutoApprovalMode: "disabled",
62+
dcodeAutoApprovalRequestedExplicitly: false,
63+
observabilityEnabled: false,
64+
observabilityRequestedExplicitly: false,
65+
policyTier: null,
66+
baseImageResolutionHint: null,
67+
};
68+
69+
function makeInput(overrides: Partial<RebuildRecreatePhaseInput> = {}): RebuildRecreatePhaseInput {
70+
return {
71+
sandboxName: SANDBOX_NAME,
72+
sandboxEntry: { name: SANDBOX_NAME, agent: "openclaw" },
73+
sessionSnapshot: onboardSession.createSession({ sandboxName: SANDBOX_NAME }),
74+
sessionMatchesSandbox: true,
75+
durableConfig,
76+
resumeConfig: compatibleResumeConfig,
77+
recreateOptions,
78+
fromDockerfile: null,
79+
rebuildAgent: "openclaw",
80+
messagingPlan: null,
81+
rebuildsHermesSandbox: false,
82+
hermesToolGateways: [],
83+
hasHermesToolGateways: false,
84+
sessionPolicyPresets: ["telegram"],
85+
credentialEnv: "COMPATIBLE_API_KEY",
86+
baseImagePreflight: { ok: true, imageRef: null, overrideEnvVar: null },
87+
recoveryRecreate: true,
88+
registryRollback: { recordRemoval: vi.fn(), restoreForRetry: vi.fn() },
89+
backupManifest: null,
90+
mcpEntries: [],
91+
rebuildShieldsWindow: { relocked: false, wasLocked: false },
92+
relockShieldsIfNeeded: vi.fn(() => true),
93+
onCreated: vi.fn(),
94+
log: vi.fn(),
95+
bail: vi.fn((message: string): never => {
96+
throw new Error(`bail: ${message}`);
97+
}),
98+
...overrides,
99+
};
100+
}
101+
102+
describe("rebuild recreate compatible-endpoint reasoning handoff (#7940)", () => {
103+
let session: Session;
104+
let previousReasoning: string | undefined;
105+
let previousReasoningEffort: string | undefined;
106+
107+
beforeEach(() => {
108+
previousReasoning = process.env.NEMOCLAW_REASONING;
109+
previousReasoningEffort = process.env.NEMOCLAW_REASONING_EFFORT;
110+
session = onboardSession.createSession({ sandboxName: SANDBOX_NAME });
111+
vi.spyOn(console, "log").mockImplementation(() => undefined);
112+
vi.spyOn(console, "error").mockImplementation(() => undefined);
113+
vi.spyOn(onboardSession, "loadSession").mockImplementation(() => session);
114+
vi.spyOn(onboardSession, "updateSession").mockImplementation((mutator) => {
115+
session = mutator(session) ?? session;
116+
return session;
117+
});
118+
});
119+
120+
afterEach(() => {
121+
restoreEnv("NEMOCLAW_REASONING", previousReasoning);
122+
restoreEnv("NEMOCLAW_REASONING_EFFORT", previousReasoningEffort);
123+
vi.restoreAllMocks();
124+
});
125+
126+
it("scopes the recorded reasoning inputs to the recreate and restores ambient values", async () => {
127+
process.env.NEMOCLAW_REASONING = "false";
128+
process.env.NEMOCLAW_REASONING_EFFORT = "low";
129+
let observed: Record<string, string | undefined> = {};
130+
vi.spyOn(rebuildOnboardDependencies, "onboard").mockImplementation(async () => {
131+
observed = {
132+
reasoning: process.env.NEMOCLAW_REASONING,
133+
effort: process.env.NEMOCLAW_REASONING_EFFORT,
134+
};
135+
});
136+
137+
await expect(runRebuildRecreatePhase(makeInput())).resolves.toBe(true);
138+
139+
expect(observed).toEqual({ reasoning: "true", effort: "high" });
140+
expect(onboardSession.loadSession()?.compatibleEndpointReasoningEffort).toBe("high");
141+
expect(process.env.NEMOCLAW_REASONING).toBe("false");
142+
expect(process.env.NEMOCLAW_REASONING_EFFORT).toBe("low");
143+
});
144+
145+
it("restores absent ambient reasoning inputs after a failed recreate", async () => {
146+
delete process.env.NEMOCLAW_REASONING;
147+
delete process.env.NEMOCLAW_REASONING_EFFORT;
148+
let observedEffort: string | undefined;
149+
vi.spyOn(rebuildOnboardDependencies, "onboard").mockImplementation(async () => {
150+
observedEffort = process.env.NEMOCLAW_REASONING_EFFORT;
151+
throw new Error("inner onboard failed");
152+
});
153+
154+
await expect(runRebuildRecreatePhase(makeInput())).rejects.toThrow("bail: Recreate failed");
155+
156+
expect(observedEffort).toBe("high");
157+
expect(process.env.NEMOCLAW_REASONING).toBeUndefined();
158+
expect(process.env.NEMOCLAW_REASONING_EFFORT).toBeUndefined();
159+
});
160+
161+
it("clears a recorded effort the resume configuration no longer carries", async () => {
162+
process.env.NEMOCLAW_REASONING_EFFORT = "high";
163+
let observedEffort: string | undefined = "unset";
164+
vi.spyOn(rebuildOnboardDependencies, "onboard").mockImplementation(async () => {
165+
observedEffort = process.env.NEMOCLAW_REASONING_EFFORT;
166+
});
167+
168+
await expect(
169+
runRebuildRecreatePhase(
170+
makeInput({
171+
resumeConfig: { ...compatibleResumeConfig, compatibleEndpointReasoningEffort: null },
172+
}),
173+
),
174+
).resolves.toBe(true);
175+
176+
expect(observedEffort).toBeUndefined();
177+
expect(process.env.NEMOCLAW_REASONING_EFFORT).toBe("high");
178+
});
179+
180+
it("keeps ambient reasoning inputs isolated for a provider that cannot use them", async () => {
181+
process.env.NEMOCLAW_REASONING = "true";
182+
process.env.NEMOCLAW_REASONING_EFFORT = "high";
183+
let observed: Record<string, string | undefined> = {};
184+
vi.spyOn(rebuildOnboardDependencies, "onboard").mockImplementation(async () => {
185+
observed = {
186+
reasoning: process.env.NEMOCLAW_REASONING,
187+
effort: process.env.NEMOCLAW_REASONING_EFFORT,
188+
};
189+
});
190+
191+
await expect(
192+
runRebuildRecreatePhase(
193+
makeInput({
194+
resumeConfig: {
195+
...compatibleResumeConfig,
196+
provider: "nvidia",
197+
compatibleEndpointReasoning: null,
198+
compatibleEndpointReasoningEffort: null,
199+
},
200+
}),
201+
),
202+
).resolves.toBe(true);
203+
204+
expect(observed).toEqual({ reasoning: undefined, effort: undefined });
205+
expect(process.env.NEMOCLAW_REASONING).toBe("true");
206+
expect(process.env.NEMOCLAW_REASONING_EFFORT).toBe("high");
207+
});
208+
});

src/lib/onboard/machine/handlers/provider-inference-authoritative-recovery.test.ts

Lines changed: 116 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,4 +85,120 @@ describe("authoritative provider inference recovery", () => {
8585
endpointUrl: "https://compatible.example.test/v1",
8686
});
8787
});
88+
89+
it("keeps the recorded reasoning mode and effort through gateway-credential reuse (#7940)", async () => {
90+
const session = createSession({
91+
sandboxName: "my-assistant",
92+
provider: "compatible-endpoint",
93+
model: "mock/channels-rebuild",
94+
endpointUrl: "https://compatible.example.test/v1",
95+
credentialEnv: "COMPATIBLE_API_KEY",
96+
preferredInferenceApi: "openai-completions",
97+
compatibleEndpointReasoning: "true",
98+
compatibleEndpointReasoningEffort: "high",
99+
});
100+
// The recovered selection reused the gateway credential, so it never ran
101+
// the custom-endpoint validation that configures reasoning.
102+
const setupNim = vi.fn(async () => ({
103+
...baseSelection,
104+
model: "mock/channels-rebuild",
105+
provider: "compatible-endpoint",
106+
endpointUrl: "https://compatible.example.test/v1",
107+
credentialEnv: "COMPATIBLE_API_KEY",
108+
preferredInferenceApi: "openai-completions",
109+
compatibleEndpointReasoning: null,
110+
compatibleEndpointReasoningEffort: null,
111+
recoveredFromSandbox: true,
112+
skipHostInferenceSmoke: true,
113+
reuseGatewayCredentialWithoutLocalKey: true,
114+
}));
115+
const { deps, calls } = createDeps({
116+
setupNim,
117+
hydrateCredentialEnv: vi.fn(() => null),
118+
isInferenceRouteReady: vi.fn(() => true),
119+
});
120+
const { receipt, ledger } = activatedRecoveryReceipt({
121+
sandboxName: "my-assistant",
122+
sessionId: session.sessionId,
123+
});
124+
const env: NodeJS.ProcessEnv = {};
125+
126+
const result = await handleProviderInferenceState({
127+
...baseOptions(deps, session),
128+
resume: true,
129+
authoritativeResumeConfig: true,
130+
providerRecoveryReceipt: receipt,
131+
providerRecoveryReceiptLedger: ledger,
132+
sandboxName: "my-assistant",
133+
selectedMessagingChannels: ["telegram"],
134+
env,
135+
});
136+
137+
expect(result.compatibleEndpointReasoning).toBe("true");
138+
expect(result.compatibleEndpointReasoningEffort).toBe("high");
139+
expect(env.NEMOCLAW_REASONING_EFFORT).toBe("high");
140+
expect(calls.complete).toHaveBeenCalledWith(
141+
"provider_selection",
142+
expect.objectContaining({ compatibleEndpointReasoningEffort: "high" }),
143+
);
144+
});
145+
146+
it("names the recorded values it replays over a conflicting ambient request (#7462)", async () => {
147+
const session = createSession({
148+
sandboxName: "my-assistant",
149+
provider: "compatible-endpoint",
150+
model: "mock/channels-rebuild",
151+
endpointUrl: "https://compatible.example.test/v1",
152+
credentialEnv: "COMPATIBLE_API_KEY",
153+
preferredInferenceApi: "openai-completions",
154+
compatibleEndpointReasoning: "true",
155+
compatibleEndpointReasoningEffort: "high",
156+
});
157+
const setupNim = vi.fn(async () => ({
158+
...baseSelection,
159+
model: "mock/channels-rebuild",
160+
provider: "compatible-endpoint",
161+
endpointUrl: "https://compatible.example.test/v1",
162+
credentialEnv: "COMPATIBLE_API_KEY",
163+
preferredInferenceApi: "openai-completions",
164+
compatibleEndpointReasoning: null,
165+
compatibleEndpointReasoningEffort: null,
166+
recoveredFromSandbox: true,
167+
skipHostInferenceSmoke: true,
168+
reuseGatewayCredentialWithoutLocalKey: true,
169+
}));
170+
const { deps, calls } = createDeps({
171+
setupNim,
172+
hydrateCredentialEnv: vi.fn(() => null),
173+
isInferenceRouteReady: vi.fn(() => true),
174+
});
175+
const { receipt, ledger } = activatedRecoveryReceipt({
176+
sandboxName: "my-assistant",
177+
sessionId: session.sessionId,
178+
});
179+
// The caller exported values that disagree with the recorded ones.
180+
const env: NodeJS.ProcessEnv = {
181+
NEMOCLAW_REASONING: "false",
182+
NEMOCLAW_REASONING_EFFORT: "low",
183+
};
184+
185+
const result = await handleProviderInferenceState({
186+
...baseOptions(deps, session),
187+
resume: true,
188+
authoritativeResumeConfig: true,
189+
providerRecoveryReceipt: receipt,
190+
providerRecoveryReceiptLedger: ledger,
191+
sandboxName: "my-assistant",
192+
selectedMessagingChannels: ["telegram"],
193+
env,
194+
});
195+
196+
const logged = calls.log.mock.calls.map((call) => String(call[0])).join("\n");
197+
expect(logged).toContain("Ignoring NEMOCLAW_REASONING=false");
198+
expect(logged).toContain("recorded as reasoning=true");
199+
expect(logged).toContain("Ignoring NEMOCLAW_REASONING_EFFORT=low");
200+
expect(logged).toContain("reasoning effort=high");
201+
expect(result.compatibleEndpointReasoning).toBe("true");
202+
expect(result.compatibleEndpointReasoningEffort).toBe("high");
203+
});
88204
});

0 commit comments

Comments
 (0)