diff --git a/src/lib/onboard.ts b/src/lib/onboard.ts index 7412b4b2e2..0f76601260 100644 --- a/src/lib/onboard.ts +++ b/src/lib/onboard.ts @@ -295,6 +295,7 @@ const { resolveSandboxImageTagFromCreateOutput } = const nim: typeof import("./inference/nim") = require("./inference/nim"); const onboardSession: typeof import("./state/onboard-session") = require("./state/onboard-session"); const { OnboardRuntimeBoundary }: typeof import("./onboard/runtime-boundary") = require("./onboard/runtime-boundary"); +const { handleAgentSetupState }: typeof import("./onboard/machine/handlers/agent-setup") = require("./onboard/machine/handlers/agent-setup"); const { handleGatewayState }: typeof import("./onboard/machine/handlers/gateway") = require("./onboard/machine/handlers/gateway"); const { handlePreflightState }: typeof import("./onboard/machine/handlers/preflight") = require("./onboard/machine/handlers/preflight"); const { handleProviderInferenceState }: typeof import("./onboard/machine/handlers/provider-inference") = require("./onboard/machine/handlers/provider-inference"); @@ -9543,41 +9544,39 @@ async function onboard(opts: OnboardOptions = {}): Promise { selectedMessagingChannels = sandboxStateResult.selectedMessagingChannels; const webSearchSupported = sandboxStateResult.webSearchSupported; - if (agent) { - await agentOnboard.handleAgentSetup(sandboxName, model, provider, agent, resume, session, { - step, - runCaptureOpenshell, - openshellShellCommand, - openshellBinary: getOpenshellBinary(), + const agentSetupResult = await handleAgentSetupState({ + agent, + sandboxName, + model, + provider, + resume, + session, + hermesAuthMethod, + hermesToolGateways, + deps: { + handleAgentSetup: agentOnboard.handleAgentSetup, + agentSetupContext: () => ({ + step, + runCaptureOpenshell, + openshellShellCommand, + openshellBinary: getOpenshellBinary(), + startRecordedStep, + recordStepComplete, + recordStepFailed, + skippedStepMessage, + }), + ensureAgentDashboardForward, + recordStepSkipped, + isOpenclawReady, + skippedStepMessage, startRecordedStep, + setupOpenclaw, + syncNemoClawConfigInSandbox, recordStepComplete, - recordStepFailed, - skippedStepMessage, - }); - ensureAgentDashboardForward(sandboxName, agent); - await recordStepSkipped("openclaw"); - } else { - const resumeOpenclaw = resume && sandboxName && isOpenclawReady(sandboxName); - if (resumeOpenclaw) { - skippedStepMessage("openclaw", sandboxName); - // Rebuild leaves /sandbox/.nemoclaw/config.json as Dockerfile's - // zero-byte placeholder; re-sync to avoid loadOnboardConfig - // SyntaxError. Fixes #3999. - syncNemoClawConfigInSandbox(sandboxName, provider, model); - await recordStepComplete( - "openclaw", - toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), - ); - } else { - await startRecordedStep("openclaw", { sandboxName, provider, model }); - await setupOpenclaw(sandboxName, model, provider); - await recordStepComplete( - "openclaw", - toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), - ); - } - await recordStepSkipped("agent_setup"); - } + toSessionUpdates: (updates) => toSessionUpdates(updates as Parameters[0]), + }, + }); + session = agentSetupResult.session; const latestSession = onboardSession.loadSession(); const recordedPolicyPresets = Array.isArray(latestSession?.policyPresets) diff --git a/src/lib/onboard/machine/handlers/agent-setup.test.ts b/src/lib/onboard/machine/handlers/agent-setup.test.ts new file mode 100644 index 0000000000..ba99281a1e --- /dev/null +++ b/src/lib/onboard/machine/handlers/agent-setup.test.ts @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { describe, expect, it, vi } from "vitest"; + +import { createSession, type Session, type SessionUpdates } from "../../../state/onboard-session"; +import { handleAgentSetupState, type AgentSetupStateOptions } from "./agent-setup"; + +type Agent = { name: string; displayName: string }; + +function createDeps(overrides: Partial["deps"]> = {}) { + let session = createSession(); + const calls = { + handleAgentSetup: vi.fn(async () => undefined), + context: vi.fn(() => ({ ctx: true })), + ensureDashboard: vi.fn(() => 18789), + skipped: vi.fn(async (stepName: string) => { + session.steps[stepName].status = "skipped"; + return session; + }), + openclawReady: vi.fn(() => false), + skippedMessage: vi.fn(), + startStep: vi.fn(async () => undefined), + setupOpenclaw: vi.fn(async () => undefined), + syncConfig: vi.fn(), + complete: vi.fn(async (stepName: string, updates: SessionUpdates = {}) => { + session.steps[stepName].status = "complete"; + Object.assign(session, updates); + return session; + }), + }; + return { + calls, + deps: { + handleAgentSetup: calls.handleAgentSetup, + agentSetupContext: calls.context, + ensureAgentDashboardForward: calls.ensureDashboard, + recordStepSkipped: calls.skipped, + isOpenclawReady: calls.openclawReady, + skippedStepMessage: calls.skippedMessage, + startRecordedStep: calls.startStep, + setupOpenclaw: calls.setupOpenclaw, + syncNemoClawConfigInSandbox: calls.syncConfig, + recordStepComplete: calls.complete, + toSessionUpdates: (updates: Record) => updates as SessionUpdates, + ...overrides, + }, + }; +} + +function baseOptions( + deps: AgentSetupStateOptions["deps"], + agent: Agent | null = null, +): AgentSetupStateOptions { + return { + agent, + sandboxName: "my-assistant", + model: "model", + provider: "provider", + resume: false, + session: createSession(), + hermesAuthMethod: null, + hermesToolGateways: [], + deps, + }; +} + +describe("handleAgentSetupState", () => { + it("delegates non-OpenClaw agent setup and skips openclaw", async () => { + const { deps, calls } = createDeps(); + const agent = { name: "hermes", displayName: "Hermes" }; + const session = createSession(); + + const result = await handleAgentSetupState({ ...baseOptions(deps, agent), session, resume: true }); + + expect(calls.handleAgentSetup).toHaveBeenCalledWith( + "my-assistant", + "model", + "provider", + agent, + true, + session, + { ctx: true }, + ); + expect(calls.ensureDashboard).toHaveBeenCalledWith("my-assistant", agent); + expect(calls.skipped).toHaveBeenCalledWith("openclaw"); + expect(calls.setupOpenclaw).not.toHaveBeenCalled(); + expect(result.session?.steps.openclaw.status).toBe("skipped"); + }); + + it("skips OpenClaw setup on resume when OpenClaw is ready", async () => { + const { deps, calls } = createDeps({ isOpenclawReady: vi.fn(() => true) }); + + const result = await handleAgentSetupState({ ...baseOptions(deps), resume: true }); + + expect(calls.skippedMessage).toHaveBeenCalledWith("openclaw", "my-assistant"); + expect(calls.startStep).not.toHaveBeenCalled(); + expect(calls.setupOpenclaw).not.toHaveBeenCalled(); + expect(calls.syncConfig).toHaveBeenCalledWith("my-assistant", "provider", "model"); + expect(calls.complete).toHaveBeenCalledWith( + "openclaw", + expect.objectContaining({ sandboxName: "my-assistant", provider: "provider", model: "model" }), + ); + expect(calls.skipped).toHaveBeenCalledWith("agent_setup"); + expect(result.session).toMatchObject({ + sandboxName: "my-assistant", + provider: "provider", + model: "model", + steps: { openclaw: { status: "complete" }, agent_setup: { status: "skipped" } }, + }); + }); + + it("runs OpenClaw setup and skips agent_setup for the default agent", async () => { + const { deps, calls } = createDeps(); + + const result = await handleAgentSetupState({ + ...baseOptions(deps), + hermesAuthMethod: "oauth", + hermesToolGateways: ["github"], + }); + + expect(calls.startStep).toHaveBeenCalledWith("openclaw", { + sandboxName: "my-assistant", + provider: "provider", + model: "model", + }); + expect(calls.setupOpenclaw).toHaveBeenCalledWith("my-assistant", "model", "provider"); + expect(calls.syncConfig).not.toHaveBeenCalled(); + expect(calls.complete).toHaveBeenCalledWith( + "openclaw", + expect.objectContaining({ + sandboxName: "my-assistant", + provider: "provider", + model: "model", + hermesAuthMethod: "oauth", + hermesToolGateways: ["github"], + }), + ); + expect(calls.skipped).toHaveBeenCalledWith("agent_setup"); + expect(result.session).toMatchObject({ + sandboxName: "my-assistant", + provider: "provider", + model: "model", + hermesAuthMethod: "oauth", + hermesToolGateways: ["github"], + steps: { openclaw: { status: "complete" }, agent_setup: { status: "skipped" } }, + }); + }); + + it("returns a session when the input session is null", async () => { + const { deps } = createDeps(); + + const result = await handleAgentSetupState({ ...baseOptions(deps), session: null }); + + expect(result.session).toMatchObject({ + sandboxName: "my-assistant", + provider: "provider", + model: "model", + steps: { openclaw: { status: "complete" }, agent_setup: { status: "skipped" } }, + }); + }); +}); diff --git a/src/lib/onboard/machine/handlers/agent-setup.ts b/src/lib/onboard/machine/handlers/agent-setup.ts new file mode 100644 index 0000000000..803c67754c --- /dev/null +++ b/src/lib/onboard/machine/handlers/agent-setup.ts @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { Session, SessionUpdates } from "../../../state/onboard-session"; + +export interface AgentSetupStateOptions { + agent: Agent | null; + sandboxName: string; + model: string; + provider: string; + resume: boolean; + session: Session | null; + hermesAuthMethod: string | null; + hermesToolGateways: string[]; + deps: { + handleAgentSetup( + sandboxName: string, + model: string, + provider: string, + agent: Agent, + resume: boolean, + session: Session | null, + context: unknown, + ): Promise; + agentSetupContext(): unknown; + ensureAgentDashboardForward(sandboxName: string, agent: Agent): number; + recordStepSkipped(stepName: string): Promise; + isOpenclawReady(sandboxName: string): boolean; + skippedStepMessage(stepName: string, detail?: string | null): void; + startRecordedStep( + stepName: string, + updates: { sandboxName: string; provider: string; model: string }, + ): Promise; + setupOpenclaw(sandboxName: string, model: string, provider: string): Promise; + syncNemoClawConfigInSandbox(sandboxName: string, provider: string, model: string): void; + recordStepComplete(stepName: string, updates: SessionUpdates): Promise; + toSessionUpdates(updates: Record): SessionUpdates; + }; +} + +export interface AgentSetupStateResult { + session: Session | null; +} + +export async function handleAgentSetupState({ + agent, + sandboxName, + model, + provider, + resume, + session, + hermesAuthMethod, + hermesToolGateways, + deps, +}: AgentSetupStateOptions): Promise { + if (agent) { + await deps.handleAgentSetup( + sandboxName, + model, + provider, + agent, + resume, + session, + deps.agentSetupContext(), + ); + deps.ensureAgentDashboardForward(sandboxName, agent); + session = await deps.recordStepSkipped("openclaw"); + return { session }; + } + + const resumeOpenclaw = resume && sandboxName && deps.isOpenclawReady(sandboxName); + if (resumeOpenclaw) { + deps.skippedStepMessage("openclaw", sandboxName); + deps.syncNemoClawConfigInSandbox(sandboxName, provider, model); + await deps.recordStepComplete( + "openclaw", + deps.toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), + ); + } else { + await deps.startRecordedStep("openclaw", { sandboxName, provider, model }); + await deps.setupOpenclaw(sandboxName, model, provider); + await deps.recordStepComplete( + "openclaw", + deps.toSessionUpdates({ sandboxName, provider, model, hermesAuthMethod, hermesToolGateways }), + ); + } + session = await deps.recordStepSkipped("agent_setup"); + return { session }; +}