Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 18 additions & 0 deletions src/lib/onboard/machine/flow-context.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { describe, expect, it } from "vitest";

import { createSession } from "../../state/onboard-session";
import {
assertProviderModelSelectedContext,
assertProviderSelectedContext,
assertSandboxCreatedContext,
mergeOnboardFlowContext,
Expand Down Expand Up @@ -89,6 +90,17 @@ describe("onboard flow context helpers", () => {
});
});

it("asserts provider/model-selected context before consumers use provider output", () => {
const context = mergeOnboardFlowContext(baseContext(), {
provider: "nvidia-prod",
model: "model",
});

expect(() =>
assertProviderModelSelectedContext(context, "provider inference result"),
).not.toThrow();
});

it("asserts provider-selected context before sandbox setup", () => {
const context = mergeOnboardFlowContext(baseContext(), {
provider: "nvidia-prod",
Expand All @@ -98,6 +110,12 @@ describe("onboard flow context helpers", () => {
expect(() => assertProviderSelectedContext(context, "sandbox setup")).not.toThrow();
});

it("rejects missing provider/model-selected context fields", () => {
expect(() =>
assertProviderModelSelectedContext(baseContext(), "provider inference result"),
).toThrow(/Onboarding state is incomplete before provider inference result\./);
});

it("rejects missing provider-selected context fields", () => {
expect(() => assertProviderSelectedContext(baseContext(), "sandbox setup")).toThrow(
/Onboarding state is incomplete before sandbox setup\./,
Expand Down
12 changes: 11 additions & 1 deletion src/lib/onboard/machine/flow-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,11 +77,21 @@ export interface SandboxCreatedContextUpdate {
webSearchSupported: boolean;
}

export function assertProviderModelSelectedContext<Context extends OnboardFlowContext>(
context: Context,
stepName: string,
): asserts context is ProviderModelSelectedOnboardFlowContext<Context> {
if (!context.model || !context.provider) {
throw new Error(`Onboarding state is incomplete before ${stepName}.`);
}
}

export function assertProviderSelectedContext<Context extends OnboardFlowContext>(
context: Context,
stepName: string,
): asserts context is ProviderSelectedOnboardFlowContext<Context> {
if (!context.model || !context.provider || !context.sandboxGpuConfig) {
assertProviderModelSelectedContext(context, stepName);
if (!context.sandboxGpuConfig) {
throw new Error(`Onboarding state is incomplete before ${stepName}.`);
}
}
Expand Down
39 changes: 35 additions & 4 deletions src/lib/onboard/machine/flow-phases/provider-sandbox.test.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,16 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import { describe, expect, it } from "vitest";
import { describe, expect, it, vi } from "vitest";

import { createSession } from "../../../state/onboard-session";
import { advanceTo, branchTo } from "../result";
import type { OnboardFlowContext } from "../flow-context";
import { advanceTo, branchTo } from "../result";
import { createProviderInferencePhase, createSandboxPhase } from "./provider-sandbox";

function context(): OnboardFlowContext<null, null, { mode: string }> {
function context(
patch: Partial<OnboardFlowContext<null, null, { mode: string }>> = {},
): OnboardFlowContext<null, null, { mode: string }> {
return {
resume: false,
fresh: false,
Expand All @@ -32,19 +34,25 @@ function context(): OnboardFlowContext<null, null, { mode: string }> {
gpu: null,
sandboxGpuConfig: { mode: "0" },
gpuPassthrough: false,
...patch,
};
}

describe("provider/sandbox flow phases", () => {
it("maps provider inference context updates and ordered FSM results", async () => {
const phase = createProviderInferencePhase(async () => ({
context: {
session: createSession(),
sandboxName: "my-assistant",
provider: "nvidia-prod",
model: "model",
endpointUrl: "https://example.com/v1",
credentialEnv: "NVIDIA_INFERENCE_API_KEY",
hermesAuthMethod: null,
hermesToolGateways: [],
preferredInferenceApi: "openai-responses",
nimContainer: null,
webSearchConfig: null,
},
result: [advanceTo("inference"), advanceTo("sandbox")],
}));
Expand All @@ -67,14 +75,18 @@ describe("provider/sandbox flow phases", () => {
});
const phase = createSandboxPhase(async () => ({
context: {
session: createSession(),
sandboxName: "my-assistant",
webSearchConfig: null,
selectedMessagingChannels: ["telegram"],
webSearchSupported: true,
},
result: branchResult,
}));

const result = await phase.run(context());
const result = await phase.run(
context({ model: "model", provider: "nvidia-prod", sandboxGpuConfig: { mode: "0" } }),
);

expect(phase.state).toBe("sandbox");
expect(result.context).toMatchObject({
Expand All @@ -84,4 +96,23 @@ describe("provider/sandbox flow phases", () => {
});
expect(result.result).toEqual(branchResult);
});

it("rejects sandbox phase execution before sandbox GPU config is selected", async () => {
const runSandbox = vi.fn(async () => ({
context: {
session: createSession(),
sandboxName: "my-assistant",
webSearchConfig: null,
selectedMessagingChannels: [],
webSearchSupported: false,
},
result: branchTo("openclaw"),
}));
const phase = createSandboxPhase(runSandbox);

await expect(
phase.run(context({ model: "model", provider: "nvidia-prod", sandboxGpuConfig: null })),
).rejects.toThrow(/Onboarding state is incomplete before sandbox setup\./);
expect(runSandbox).not.toHaveBeenCalled();
});
});
28 changes: 21 additions & 7 deletions src/lib/onboard/machine/flow-phases/provider-sandbox.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,32 @@
// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved.
// SPDX-License-Identifier: Apache-2.0

import type { OnboardFlowContext, OnboardFlowPhaseResult } from "../flow-context";
import { mergeOnboardFlowContext, onboardFlowPhaseResult } from "../flow-context";
import type {
OnboardFlowContext,
OnboardFlowPhaseResult,
ProviderModelSelectedContextUpdate,
ProviderModelSelectedOnboardFlowContext,
SandboxCreatedContextUpdate,
} from "../flow-context";
import {
assertProviderSelectedContext,
mergeProviderModelSelectedContext,
mergeSandboxCreatedContext,
onboardFlowPhaseResult,
} from "../flow-context";
import type { OnboardSequencePhase } from "../sequence-runner";

type ProviderInferencePhaseHandler<Context extends OnboardFlowContext> = (
context: Context,
) => Promise<{
context: Partial<Context>;
context: ProviderModelSelectedContextUpdate;
result: OnboardFlowPhaseResult<Context>["result"];
}>;

type SandboxPhaseHandler<Context extends OnboardFlowContext> = (context: Context) => Promise<{
context: Partial<Context>;
type SandboxPhaseHandler<Context extends OnboardFlowContext> = (
context: ProviderModelSelectedOnboardFlowContext<Context>,
) => Promise<{
context: SandboxCreatedContextUpdate;
result: OnboardFlowPhaseResult<Context>["result"];
}>;

Expand All @@ -25,7 +38,7 @@ export function createProviderInferencePhase<Context extends OnboardFlowContext>
async run(context) {
const result = await runProviderInference(context);
return onboardFlowPhaseResult(
mergeOnboardFlowContext(context, result.context),
mergeProviderModelSelectedContext(context, result.context),
result.result,
);
},
Expand All @@ -38,9 +51,10 @@ export function createSandboxPhase<Context extends OnboardFlowContext>(
return {
state: "sandbox",
async run(context) {
assertProviderSelectedContext(context, "sandbox setup");
const result = await runSandbox(context);
return onboardFlowPhaseResult(
mergeOnboardFlowContext(context, result.context),
mergeSandboxCreatedContext(context, result.context),
result.result,
);
},
Expand Down
52 changes: 48 additions & 4 deletions src/lib/onboard/machine/flow-sequence.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@ import {
filterSafeUpdates,
MACHINE_SNAPSHOT_VERSION,
normalizeSession,
sanitizeFailure,
type Session,
type SessionUpdates,
sanitizeFailure,
} from "../../state/onboard-session";
import type { OnboardFlowContext, OnboardFlowPhaseResult } from "./flow-context";
import { onboardFlowPhaseResult } from "./flow-context";
Expand All @@ -21,7 +21,7 @@ import { runOnboardSequenceWithRunner } from "./sequence-runner";

type Context = OnboardFlowContext<null, { type: string }, { mode: string }>;

function context(): Context {
function context(patch: Partial<Context> = {}): Context {
return {
resume: false,
fresh: false,
Expand All @@ -45,6 +45,7 @@ function context(): Context {
gpu: null,
sandboxGpuConfig: { mode: "0" },
gpuPassthrough: false,
...patch,
};
}

Expand Down Expand Up @@ -141,8 +142,10 @@ describe("onboard flow phase sequence", () => {
preflight: async (ctx) =>
result({ ...ctx, gpu: { type: "nvidia" }, gpuPassthrough: true }, "gateway"),
gateway: async (ctx) => result(ctx, "provider_selection"),
providerInference: async (ctx) => result(ctx, "sandbox"),
sandbox: async (ctx) => onboardFlowPhaseResult(ctx, branchTo("openclaw")),
providerInference: async (ctx) =>
result({ ...ctx, provider: "nvidia", model: "model" }, "sandbox"),
sandbox: async (ctx) =>
onboardFlowPhaseResult({ ...ctx, sandboxName: "my-assistant" }, branchTo("openclaw")),
openclaw: async (ctx) => result(ctx, "policies"),
agentSetup: async (ctx) => result(ctx, "policies"),
policies: async (ctx) => result(ctx, "finalizing"),
Expand All @@ -156,6 +159,47 @@ describe("onboard flow phase sequence", () => {
expect(preflight.result).toMatchObject({ next: "gateway" });
});

it("rejects provider inference results that omit provider or model", async () => {
const phases = buildOnboardFlowPhaseSequence<Context>({
preflight: async (ctx) => result(ctx, "gateway"),
gateway: async (ctx) => result(ctx, "provider_selection"),
providerInference: async (ctx) =>
result({ ...ctx, model: "model", provider: null }, "sandbox"),
sandbox: async (ctx) => onboardFlowPhaseResult(ctx, branchTo("openclaw")),
openclaw: async (ctx) => result(ctx, "policies"),
agentSetup: async (ctx) => result(ctx, "policies"),
policies: async (ctx) => result(ctx, "finalizing"),
finalization: async (ctx) => result(ctx, "post_verify"),
postVerify: async (ctx) => onboardFlowPhaseResult(ctx, completeOnboardMachine()),
});

await expect(phases[2].run(context())).rejects.toThrow(
/Onboarding state is incomplete before provider inference result\./,
);
});

it("rejects sandbox results that omit sandbox name", async () => {
const phases = buildOnboardFlowPhaseSequence<Context>({
preflight: async (ctx) => result(ctx, "gateway"),
gateway: async (ctx) => result(ctx, "provider_selection"),
providerInference: async (ctx) =>
result({ ...ctx, provider: "nvidia", model: "model" }, "sandbox"),
sandbox: async (ctx) =>
onboardFlowPhaseResult({ ...ctx, sandboxName: null }, branchTo("openclaw")),
openclaw: async (ctx) => result(ctx, "policies"),
agentSetup: async (ctx) => result(ctx, "policies"),
policies: async (ctx) => result(ctx, "finalizing"),
finalization: async (ctx) => result(ctx, "post_verify"),
postVerify: async (ctx) => onboardFlowPhaseResult(ctx, completeOnboardMachine()),
});

await expect(
phases[3].run(
context({ provider: "nvidia", model: "model", sandboxGpuConfig: { mode: "0" } }),
),
).rejects.toThrow(/Onboarding state is incomplete before sandbox result\./);
});

it("runs ordered provider results through runtime transition validation", async () => {
const initialSession = createSession({
machine: {
Expand Down
37 changes: 35 additions & 2 deletions src/lib/onboard/machine/flow-sequence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// SPDX-License-Identifier: Apache-2.0

import type { OnboardFlowContext, OnboardFlowPhaseResult } from "./flow-context";
import { assertProviderModelSelectedContext, assertSandboxCreatedContext } from "./flow-context";
import {
createAgentSetupPhase,
createFinalizationPhase,
Expand Down Expand Up @@ -45,8 +46,40 @@ export function buildOnboardFlowPhaseSequence<Context extends OnboardFlowContext
const result = await handlers.gateway(context);
return { session: result.context.session, result: result.result };
}),
createProviderInferencePhase((context) => handlers.providerInference(context)),
createSandboxPhase((context) => handlers.sandbox(context)),
createProviderInferencePhase(async (context) => {
const result = await handlers.providerInference(context);
assertProviderModelSelectedContext(result.context, "provider inference result");
return {
context: {
session: result.context.session,
sandboxName: result.context.sandboxName,
model: result.context.model,
provider: result.context.provider,
endpointUrl: result.context.endpointUrl,
credentialEnv: result.context.credentialEnv,
hermesAuthMethod: result.context.hermesAuthMethod,
hermesToolGateways: result.context.hermesToolGateways,
preferredInferenceApi: result.context.preferredInferenceApi,
nimContainer: result.context.nimContainer,
webSearchConfig: result.context.webSearchConfig,
},
result: result.result,
};
}),
createSandboxPhase(async (context) => {
const result = await handlers.sandbox(context);
assertSandboxCreatedContext(result.context, "sandbox result");
return {
context: {
session: result.context.session,
sandboxName: result.context.sandboxName,
webSearchConfig: result.context.webSearchConfig,
selectedMessagingChannels: result.context.selectedMessagingChannels,
webSearchSupported: result.context.webSearchSupported,
},
result: result.result,
};
}),
createOpenclawSetupPhase((context) => handlers.openclaw(context)),
createAgentSetupPhase((context) => handlers.agentSetup(context)),
createPoliciesPhase((context) => handlers.policies(context)),
Expand Down