diff --git a/docs/inference/switch-inference-providers.mdx b/docs/inference/switch-inference-providers.mdx index f5cedcf913..4f3ba907f0 100644 --- a/docs/inference/switch-inference-providers.mdx +++ b/docs/inference/switch-inference-providers.mdx @@ -49,6 +49,7 @@ An OpenClaw native-Anthropic route and a Hermes OpenAI-frontend route therefore NemoClaw checks stopped sandboxes because they depend on that route when restarted. If a registered same-gateway sandbox lacks durable provider or model metadata, or a custom route lacks durable endpoint or API-family metadata, NemoClaw fails closed until you remove and re-onboard that sandbox with complete route metadata. When a route conflicts, onboarding, runtime switching, and connect-time repair exit non-zero before changing the gateway and name the affected sandboxes. +If the live gateway route still ends up differing from a sandbox's recorded route, for example after a direct `openshell inference set`, `$$nemoclaw status` prints a warning naming both routes and the supported command to realign or adopt the live route. Align the routes, remove the conflicting sandbox, or onboard it with another `NEMOCLAW_GATEWAY_PORT`. diff --git a/src/lib/actions/sandbox/connect-route-lifecycle.test.ts b/src/lib/actions/sandbox/connect-route-lifecycle.test.ts index e7ceb276e0..5c03518db1 100644 --- a/src/lib/actions/sandbox/connect-route-lifecycle.test.ts +++ b/src/lib/actions/sandbox/connect-route-lifecycle.test.ts @@ -60,6 +60,9 @@ describe("connectSandbox route lifecycle", () => { expect(errorOutput).toContain( "Aligning the gateway to anthropic-prod/claude-sonnet-4-20250514", ); + expect(errorOutput).toContain( + "nemoclaw inference set --provider 'nvidia-prod' --model 'nvidia/nemotron-3-super-120b-a12b' --sandbox 'alpha'", + ); expect(harness.runOpenshellSpy).toHaveBeenCalledWith( [ "inference", @@ -81,6 +84,26 @@ describe("connectSandbox route lifecycle", () => { ); }); + it("shell-quotes hostile route values in drift recovery commands (#3726)", async () => { + const sandboxName = "alpha's box"; + const harness = createConnectHarness({ + inferenceGetOutput: + "Gateway inference:\n Provider: openai; touch /tmp/pwn\n Model: $(id) model\n", + registryEntry: { + name: sandboxName, + model: "claude-sonnet-4-20250514", + provider: "anthropic-prod", + }, + }); + + await expect(harness.connectSandbox(sandboxName, { probeOnly: true })).resolves.toBeUndefined(); + + const errorOutput = harness.errorSpy.mock.calls.map((call) => String(call[0] ?? "")).join("\n"); + expect(errorOutput).toContain( + "nemoclaw inference set --provider 'openai; touch /tmp/pwn' --model '$(id) model' --sandbox 'alpha'\\''s box'", + ); + }); + it("wires the forced VM DNS monkeypatch into connect route repair", async () => { vi.stubEnv("NEMOCLAW_FORCE_VM_DNS_MONKEYPATCH", "1"); try { diff --git a/src/lib/actions/sandbox/connect.ts b/src/lib/actions/sandbox/connect.ts index d82e99a6c8..00023b2978 100644 --- a/src/lib/actions/sandbox/connect.ts +++ b/src/lib/actions/sandbox/connect.ts @@ -17,8 +17,10 @@ import * as agentRuntime from "../../agent/runtime"; import { CLI_NAME } from "../../cli/branding"; import { D, G, R, YW } from "../../cli/terminal-style"; import { spawnExitCode } from "../../core/process-exit"; +import { shellQuote } from "../../core/shell-quote"; import { getNamedGatewayLifecycleState } from "../../gateway-runtime-action"; import { + formatInferenceRouteDriftForDisplay, parseGatewayInference, planInferenceRouteReconcile, sanitizeRouteValueForDisplay, @@ -733,18 +735,19 @@ function ensureSandboxInferenceRouteUnlocked( if (plan.kind === "diverged") { // Shared gateway: re-point loudly (even when quiet) — silent revert was // #3726. Values sanitized: registry/gateway strings are untrusted. - const liveProvider = sanitizeRouteValueForDisplay(plan.live.provider); - const liveModel = sanitizeRouteValueForDisplay(plan.live.model); - console.error( - ` ${YW}Warning: gateway inference route (${liveProvider}/${liveModel}) ` + - `differs from the recorded route for sandbox '${sandboxName}' (${recordedRoute}).${R}`, + const display = formatInferenceRouteDriftForDisplay( + plan.live, + plan.recorded, + `for sandbox '${sandboxName}'`, ); + const { liveProvider, liveModel } = display; + console.error(` ${YW}Warning: ${display.warning}${R}`); console.error( ` ${YW}Aligning the gateway to ${recordedRoute}. To keep ` + `${liveProvider}/${liveModel}, set it the supported way:${R}`, ); console.error( - ` ${CLI_NAME} inference set --provider ${liveProvider} --model ${liveModel} --sandbox ${sandboxName}`, + ` ${CLI_NAME} inference set --provider ${shellQuote(liveProvider)} --model ${shellQuote(liveModel)} --sandbox ${shellQuote(sandboxName)}`, ); } else if (!quiet) { // plan.kind === "repair": empty gateway, genuine repair — quiet-aware. diff --git a/src/lib/actions/sandbox/status-flow.test.ts b/src/lib/actions/sandbox/status-flow.test.ts index 5dae9c4dbc..ccabc1c582 100644 --- a/src/lib/actions/sandbox/status-flow.test.ts +++ b/src/lib/actions/sandbox/status-flow.test.ts @@ -24,6 +24,60 @@ describe("showSandboxStatus flow", () => { resetStatusFlowModuleCache(); }); + it("warns when the live gateway route differs from the sandbox's recorded route (#6315)", async () => { + const harness = createStatusFlowHarness({ + currentProvider: "openai", + currentModel: "gpt-5.2", + routeDrift: { + live: { provider: "openai", model: "gpt-5.2" }, + recorded: { provider: "nvidia", model: "nvidia/nemotron" }, + }, + }); + + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + + const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain( + "Warning: gateway inference route (openai/gpt-5.2) differs from the recorded route for this sandbox (nvidia/nvidia/nemotron).", + ); + expect(output).toContain( + "nemoclaw 'alpha' connect realigns the gateway to nvidia/nvidia/nemotron", + ); + expect(output).toContain( + "inference set --provider 'openai' --model 'gpt-5.2' --sandbox 'alpha'", + ); + }); + + it("shell-quotes hostile route values in drift recovery commands (#6315)", async () => { + const sandboxName = "alpha's box"; + const harness = createStatusFlowHarness({ + currentProvider: "openai; touch /tmp/pwn", + currentModel: "$(id) model", + routeDrift: { + live: { provider: "openai; touch /tmp/pwn", model: "$(id) model" }, + recorded: { provider: "nvidia", model: "nvidia/nemotron" }, + }, + sandboxEntry: { name: sandboxName }, + }); + + await expect(harness.showSandboxStatus(sandboxName)).resolves.toBeUndefined(); + + const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).toContain("nemoclaw 'alpha'\\''s box' connect realigns the gateway"); + expect(output).toContain( + "nemoclaw inference set --provider 'openai; touch /tmp/pwn' --model '$(id) model' --sandbox 'alpha'\\''s box'", + ); + }); + + it("prints no route drift warning when the live route matches the recorded route (#6315)", async () => { + const harness = createStatusFlowHarness(); + + await expect(harness.showSandboxStatus("alpha")).resolves.toBeUndefined(); + + const output = harness.logSpy.mock.calls.map((call) => String(call[0])).join("\n"); + expect(output).not.toContain("differs from the recorded route"); + }); + it("prints the live sandbox, inference, runtime, session, version, and recovery signals", async () => { const harness = createStatusFlowHarness(); diff --git a/src/lib/actions/sandbox/status-snapshot-route-drift.test.ts b/src/lib/actions/sandbox/status-snapshot-route-drift.test.ts new file mode 100644 index 0000000000..a720a9d3fa --- /dev/null +++ b/src/lib/actions/sandbox/status-snapshot-route-drift.test.ts @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("../../adapters/openshell/runtime", async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, captureOpenshellForStatus: vi.fn() }; +}); + +import { captureOpenshellForStatus } from "../../adapters/openshell/runtime"; +import type { SandboxEntry } from "../../state/registry"; +import { collectSandboxStatusSnapshot } from "./status-snapshot"; + +const capture = vi.mocked(captureOpenshellForStatus); + +function liveGatewayInference(provider: string, model: string, gatewayName = "nemoclaw"): void { + capture.mockImplementation(async (args) => + args.join("\0") === ["inference", "get", "-g", gatewayName].join("\0") + ? ({ + status: 0, + output: `Gateway inference:\n Provider: ${provider}\n Model: ${model}\n`, + } as Awaited>) + : ({ status: 1, output: "" } as Awaited>), + ); +} + +function snapshotDeps(entry: Partial | null) { + return { + suppressInferenceProbe: true, + deps: { + getSandbox: () => + entry + ? ({ name: "alpha", agent: "openclaw", policies: [], ...entry } as SandboxEntry) + : null, + reconcile: async () => ({ state: "present", output: "Phase: Ready" }), + }, + }; +} + +describe("collectSandboxStatusSnapshot route drift", () => { + afterEach(() => { + vi.clearAllMocks(); + }); + + it("reports drift when the live gateway route differs from the recorded route (#6315)", async () => { + liveGatewayInference("openai", "gpt-5.2"); + + const snapshot = await collectSandboxStatusSnapshot( + "alpha", + snapshotDeps({ provider: "nvidia", model: "nvidia/nemotron" }), + ); + + expect(snapshot.routeDrift).toEqual({ + live: { provider: "openai", model: "gpt-5.2" }, + recorded: { provider: "nvidia", model: "nvidia/nemotron" }, + }); + expect(snapshot.currentProvider).toBe("openai"); + expect(snapshot.currentModel).toBe("gpt-5.2"); + }); + + it("reads the sandbox's non-default gateway before computing drift (#6315)", async () => { + liveGatewayInference("openai", "gpt-5.2", "nemoclaw-9090"); + + const snapshot = await collectSandboxStatusSnapshot( + "alpha", + snapshotDeps({ + gatewayPort: 9090, + provider: "nvidia", + model: "nvidia/nemotron", + }), + ); + + expect(snapshot.routeDrift).toEqual({ + live: { provider: "openai", model: "gpt-5.2" }, + recorded: { provider: "nvidia", model: "nvidia/nemotron" }, + }); + expect(snapshot.currentProvider).toBe("openai"); + expect(snapshot.currentModel).toBe("gpt-5.2"); + }); + + it("does not fall back to the default gateway for an invalid persisted binding (#6315)", async () => { + liveGatewayInference("openai", "gpt-5.2"); + + const snapshot = await collectSandboxStatusSnapshot( + "alpha", + snapshotDeps({ + gatewayPort: 0, + provider: "nvidia", + model: "nvidia/nemotron", + }), + ); + + expect(snapshot.routeDrift).toBeNull(); + expect(snapshot.currentProvider).toBe("nvidia"); + expect(snapshot.currentModel).toBe("nvidia/nemotron"); + }); + + it("reports no drift when the live route matches the recorded route (#6315)", async () => { + liveGatewayInference("nvidia", "nvidia/nemotron"); + + const snapshot = await collectSandboxStatusSnapshot( + "alpha", + snapshotDeps({ provider: "nvidia", model: "nvidia/nemotron" }), + ); + + expect(snapshot.routeDrift).toBeNull(); + }); + + it("reports no drift when the live route is unreadable — repair, not divergence (#6315)", async () => { + capture.mockResolvedValue({ + status: 1, + output: "", + } as Awaited>); + + const snapshot = await collectSandboxStatusSnapshot( + "alpha", + snapshotDeps({ provider: "nvidia", model: "nvidia/nemotron" }), + ); + + expect(snapshot.routeDrift).toBeNull(); + expect(snapshot.currentProvider).toBe("nvidia"); + expect(snapshot.currentModel).toBe("nvidia/nemotron"); + }); + + it("reports no drift when the registry entry has no recorded route (#6315)", async () => { + liveGatewayInference("openai", "gpt-5.2"); + + const snapshot = await collectSandboxStatusSnapshot("alpha", snapshotDeps({})); + + expect(snapshot.routeDrift).toBeNull(); + }); +}); diff --git a/src/lib/actions/sandbox/status-snapshot.ts b/src/lib/actions/sandbox/status-snapshot.ts index 8092073765..3bfa975091 100644 --- a/src/lib/actions/sandbox/status-snapshot.ts +++ b/src/lib/actions/sandbox/status-snapshot.ts @@ -8,7 +8,12 @@ import { import { captureOpenshellForStatus, isCommandTimeout } from "../../adapters/openshell/runtime"; import { type AgentDefinition, getAgentRuntimeKind, loadAgent } from "../../agent/defs"; import { withStdoutRedirectedToStderr } from "../../cli/stdout-guard"; -import { parseGatewayInference } from "../../inference/config"; +import { + type GatewayInference, + parseGatewayInference, + planInferenceRouteReconcile, + type RecordedInferenceRoute, +} from "../../inference/config"; import { type ProviderHealthProbeOptions, type ProviderHealthStatus, @@ -18,9 +23,11 @@ import { type DcodeAutoApprovalMode, normalizeDcodeAutoApprovalMode, } from "../../onboard/dcode-auto-approval"; +import { resolveSandboxGatewayName } from "../../onboard/gateway-binding"; import { redact } from "../../security/redact"; import { parseSandboxPhase } from "../../state/gateway"; import * as registry from "../../state/registry"; +import { buildGatewayInferenceGetArgs } from "./connect-inference-gateway"; import { classifyInferenceRouteFailureLabel } from "./connect-inference-route-probe"; import { getSandboxDockerRuntime } from "./docker-health"; import type { SandboxGatewayState } from "./gateway-state"; @@ -156,12 +163,18 @@ export interface SandboxStatusReport { dockerPaused: boolean; } +export interface SandboxStatusRouteDrift { + live: GatewayInference; + recorded: RecordedInferenceRoute; +} + export interface SandboxStatusSnapshot { sb: registry.SandboxEntry | null; lookup: SandboxGatewayState; rpcIssue: OpenShellStateRpcIssue | null; currentModel: string; currentProvider: string; + routeDrift: SandboxStatusRouteDrift | null; inferenceHealth: ProviderHealthStatus | null; terminalRuntimeHealth: TerminalRuntimeOomProbeResult | null; } @@ -258,11 +271,13 @@ export async function collectSandboxStatusSnapshot( let liveResult: Awaited> | null = null; if (lookup.state === "present") { try { - liveResult = await (opts.deps?.captureOpenshellForStatusImpl ?? captureOpenshellForStatus)([ - "inference", - "get", - ]); + const gatewayName = resolveSandboxGatewayName(sb); + liveResult = await (opts.deps?.captureOpenshellForStatusImpl ?? captureOpenshellForStatus)( + buildGatewayInferenceGetArgs(gatewayName), + ); } catch { + // Invalid persisted gateway bindings and failed reads stay fail-closed: + // never substitute the selected/default gateway's inference route. liveResult = null; } } @@ -274,6 +289,7 @@ export async function collectSandboxStatusSnapshot( rpcIssue, currentModel: "unknown", currentProvider: "unknown", + routeDrift: null, inferenceHealth: null, terminalRuntimeHealth: null, }; @@ -282,6 +298,18 @@ export async function collectSandboxStatusSnapshot( liveResult && !isCommandTimeout(liveResult) ? parseGatewayInference(liveResult.output) : null; const currentModel = (live && live.model) || (sb && sb.model) || "unknown"; const currentProvider = (live && live.provider) || (sb && sb.provider) || "unknown"; + // Status shows the live gateway route when one is readable, which silently + // masks a route another sandbox (or a direct `openshell inference set`) + // moved from under this one — the shared-route trap of #6315. Surface the + // divergence instead of letting the live value pass as this sandbox's own. + const routeDriftPlan = + sb && sb.provider && sb.model + ? planInferenceRouteReconcile(live, { provider: sb.provider, model: sb.model }) + : null; + const routeDrift = + routeDriftPlan && routeDriftPlan.kind === "diverged" + ? { live: routeDriftPlan.live, recorded: routeDriftPlan.recorded } + : null; // When the caller has already determined that the local stack is failed // (docker daemon down, sandbox container stopped, dashboard port held), // skip the provider probe entirely. Without this gate @@ -336,6 +364,7 @@ export async function collectSandboxStatusSnapshot( rpcIssue, currentModel, currentProvider, + routeDrift, inferenceHealth, terminalRuntimeHealth, }; diff --git a/src/lib/actions/sandbox/status-text.ts b/src/lib/actions/sandbox/status-text.ts index 6a99d3495f..68307b0b40 100644 --- a/src/lib/actions/sandbox/status-text.ts +++ b/src/lib/actions/sandbox/status-text.ts @@ -5,6 +5,8 @@ import { resolveOpenshell } from "../../adapters/openshell/resolve"; import * as agentRuntime from "../../agent/runtime"; import { CLI_NAME } from "../../cli/branding"; import { D, G, R, RD, YW } from "../../cli/terminal-style"; +import { shellQuote } from "../../core/shell-quote"; +import { formatInferenceRouteDriftForDisplay } from "../../inference/config"; import type { ProviderHealthStatus } from "../../inference/health"; import * as nim from "../../inference/nim"; import * as sandboxVersion from "../../sandbox/version"; @@ -21,6 +23,7 @@ import { isInferenceHealthFailing, resolveSandboxStatusDcodeAutoApprovalMode, type SandboxStatusAgentInfo, + type SandboxStatusRouteDrift, type SandboxStatusSnapshot, } from "./status-snapshot"; @@ -31,6 +34,7 @@ export interface SandboxStatusTextContext | "lookup" | "currentModel" | "currentProvider" + | "routeDrift" | "inferenceHealth" | "terminalRuntimeHealth" > { @@ -245,6 +249,31 @@ function printAgentVersion(context: SandboxStatusTextContext, sandbox: SandboxEn } } +// The Model/Provider lines above show the live gateway route, which the +// shared per-gateway route lets another sandbox move (#6315). When it no +// longer matches this sandbox's recorded route, say so instead of presenting +// the live value as this sandbox's own; wording mirrors the connect-time +// divergence warning (#3726). +function printInferenceRouteDrift( + drift: SandboxStatusRouteDrift | null, + sandboxName: string, +): void { + if (!drift) return; + const display = formatInferenceRouteDriftForDisplay( + drift.live, + drift.recorded, + "for this sandbox", + ); + const { liveProvider, liveModel, recordedRoute } = display; + console.log(` ${YW}Warning: ${display.warning}${R}`); + console.log( + ` ${YW}${CLI_NAME} ${shellQuote(sandboxName)} connect realigns the gateway to ${recordedRoute}; to adopt the live route instead:${R}`, + ); + console.log( + ` ${CLI_NAME} inference set --provider ${shellQuote(liveProvider)} --model ${shellQuote(liveModel)} --sandbox ${shellQuote(sandboxName)}`, + ); +} + /** Render registry-backed sandbox details and return any non-fatal degraded outcome. */ export function printSandboxDetails(context: SandboxStatusTextContext): SandboxStatusTextOutcome { const { sb, currentModel, currentProvider, sandboxName } = context; @@ -255,6 +284,7 @@ export function printSandboxDetails(context: SandboxStatusTextContext): SandboxS console.log(` Sandbox: ${sb.name}`); console.log(` Model: ${currentModel}`); console.log(` Provider: ${currentProvider}`); + printInferenceRouteDrift(context.routeDrift, sb.name); printInferenceStatus(context); const inferenceExitCode = inferenceHealthExitCode(context.inferenceHealth); printSandboxGpuStatus(sb); diff --git a/src/lib/actions/sandbox/status.ts b/src/lib/actions/sandbox/status.ts index 4715caf25e..0bb69da079 100644 --- a/src/lib/actions/sandbox/status.ts +++ b/src/lib/actions/sandbox/status.ts @@ -78,6 +78,7 @@ export async function showSandboxStatus(sandboxName: string): Promise { rpcIssue, currentModel, currentProvider, + routeDrift, inferenceHealth, terminalRuntimeHealth, } = snapshot; @@ -105,6 +106,7 @@ export async function showSandboxStatus(sandboxName: string): Promise { lookup, currentModel, currentProvider, + routeDrift, inferenceHealth, terminalRuntimeHealth, statusAgent, diff --git a/src/lib/inference/config.test.ts b/src/lib/inference/config.test.ts index e5e0c950c2..9a215195d1 100644 --- a/src/lib/inference/config.test.ts +++ b/src/lib/inference/config.test.ts @@ -12,6 +12,7 @@ import { DEFAULT_OLLAMA_MODEL, DEFAULT_ROUTE_CREDENTIAL_ENV, DEFAULT_ROUTE_PROFILE, + formatInferenceRouteDriftForDisplay, getCompatibleAnthropicOpenAiSurfaceBaseUrl, getOpenClawPrimaryModel, getProviderSelectionConfig, @@ -602,3 +603,21 @@ describe("sanitizeRouteValueForDisplay", () => { expect(sanitizeRouteValueForDisplay("nvidia-prod")).toBe("nvidia-prod"); }); }); + +describe("formatInferenceRouteDriftForDisplay", () => { + it("shares one sanitized warning contract across status and connect", () => { + expect( + formatInferenceRouteDriftForDisplay( + { provider: "openai\u001b[2J", model: "gpt-5.2\n" }, + { provider: "nvidia", model: "nvidia/nemotron" }, + "for sandbox 'alpha'\r", + ), + ).toEqual({ + liveProvider: "openai[2J", + liveModel: "gpt-5.2", + recordedRoute: "nvidia/nvidia/nemotron", + warning: + "gateway inference route (openai[2J/gpt-5.2) differs from the recorded route for sandbox 'alpha' (nvidia/nvidia/nemotron).", + }); + }); +}); diff --git a/src/lib/inference/config.ts b/src/lib/inference/config.ts index dc4cb93d4d..a3353fb47b 100644 --- a/src/lib/inference/config.ts +++ b/src/lib/inference/config.ts @@ -393,3 +393,27 @@ export function planInferenceRouteReconcile( export function sanitizeRouteValueForDisplay(value: string | null | undefined): string { return (value ?? "").replace(/[\u0000-\u001f\u007f-\u009f]/g, ""); } + +export interface InferenceRouteDriftDisplay { + liveProvider: string; + liveModel: string; + recordedRoute: string; + warning: string; +} + +export function formatInferenceRouteDriftForDisplay( + live: GatewayInference, + recorded: RecordedInferenceRoute, + recordedRouteOwner: string, +): InferenceRouteDriftDisplay { + const liveProvider = sanitizeRouteValueForDisplay(live.provider); + const liveModel = sanitizeRouteValueForDisplay(live.model); + const recordedRoute = `${sanitizeRouteValueForDisplay(recorded.provider)}/${sanitizeRouteValueForDisplay(recorded.model)}`; + const owner = sanitizeRouteValueForDisplay(recordedRouteOwner); + return { + liveProvider, + liveModel, + recordedRoute, + warning: `gateway inference route (${liveProvider}/${liveModel}) differs from the recorded route ${owner} (${recordedRoute}).`, + }; +} diff --git a/test/cli/status-gateway-lifecycle.test.ts b/test/cli/status-gateway-lifecycle.test.ts index 0d9528a68e..ea60c474d1 100644 --- a/test/cli/status-gateway-lifecycle.test.ts +++ b/test/cli/status-gateway-lifecycle.test.ts @@ -141,7 +141,7 @@ describe("CLI status gateway lifecycle process contracts", () => { expect(result.out).not.toContain("not verified"); const calls = fs.readFileSync(markerFile, "utf8").trim().split("\n").filter(Boolean); const sandboxGetIndex = calls.indexOf("sandbox get alpha"); - const inferenceGetIndex = calls.indexOf("inference get"); + const inferenceGetIndex = calls.indexOf("inference get -g nemoclaw"); expect(sandboxGetIndex).toBeGreaterThanOrEqual(0); expect(inferenceGetIndex).toBeGreaterThan(sandboxGetIndex); }); diff --git a/test/support/status-flow-test-harness.ts b/test/support/status-flow-test-harness.ts index a5d73c2d32..2147abe284 100644 --- a/test/support/status-flow-test-harness.ts +++ b/test/support/status-flow-test-harness.ts @@ -7,6 +7,7 @@ import { type MockInstance, vi } from "vitest"; import type { SandboxGatewayState } from "../../src/lib/actions/sandbox/gateway-state"; import type { SandboxStatusPreflightResult } from "../../src/lib/actions/sandbox/status-preflight"; +import type { SandboxStatusRouteDrift } from "../../src/lib/actions/sandbox/status-snapshot"; import type { ProviderHealthStatus } from "../../src/lib/inference/health"; type ShowSandboxStatus = typeof import("../../src/lib/actions/sandbox/status")["showSandboxStatus"]; @@ -53,6 +54,7 @@ const baseSandboxEntry = { export type StatusFlowHarnessOptions = { currentModel?: string; currentProvider?: string; + routeDrift?: SandboxStatusRouteDrift | null; inferenceHealth?: ProviderHealthStatus | null; lookup?: SandboxGatewayState; lookupState?: "present" | "missing"; @@ -136,6 +138,7 @@ export function createStatusFlowHarness(options: StatusFlowHarnessOptions = {}): rpcIssue: null, currentModel: options.currentModel ?? "nvidia/nemotron-live", currentProvider: options.currentProvider ?? "ollama-local", + routeDrift: options.routeDrift ?? null, inferenceHealth: options.inferenceHealth === undefined ? {