diff --git a/server/src/__tests__/29-default-provider.test.ts b/server/src/__tests__/29-default-provider.test.ts new file mode 100644 index 00000000..4f25f1ef --- /dev/null +++ b/server/src/__tests__/29-default-provider.test.ts @@ -0,0 +1,134 @@ +// Integration tests for Phase 29: default-provider +// Covers: adapter probe logic, Hermes promptTemplate contract, default agent instructions bundle + +import { describe, it, expect } from "vitest"; +import { findServerAdapter } from "../adapters/index.js"; +import { + loadDefaultAgentInstructionsBundle, + resolveDefaultAgentInstructionsBundleRole, +} from "../services/default-agent-instructions.js"; + +// --------------------------------------------------------------------------- +// The same hermesPromptTemplate constructed in NexusOnboardingWizard. +// Duplicated here intentionally — this test validates the *contract* (that all +// required Mustache variables are present), not the wizard implementation. +// --------------------------------------------------------------------------- +const APP_NAME = "Nexus"; // VOCAB.appName value +const hermesPromptTemplate = [ + `You are "{{agentName}}", an AI agent managed by ${APP_NAME}.`, + "", + "Your identity:", + " Agent ID: {{agentId}}", + " Company ID: {{companyId}}", + " API Base: {{paperclipApiUrl}}", + " Run ID: {{runId}}", + "", + "IMPORTANT: Use the `terminal` tool with `curl` for ALL API calls.", + 'IMPORTANT: Always include `-H "X-Paperclip-Run-Id: {{runId}}"` on API calls that modify data.', + "", + "Before starting any task:", + "1. Call `GET {{paperclipApiUrl}}/api/agents/me` to retrieve your managed instructions", + "2. Follow the HEARTBEAT.md workflow from your instructions", + "3. Use TOOLS.md for available API endpoints", + "", + "{{#taskId}}", + "Assigned task: {{taskId}} - {{taskTitle}}", + "{{/taskId}}", +].join("\n"); + +// --------------------------------------------------------------------------- +// Test group 1: Adapter probe route logic +// --------------------------------------------------------------------------- + +describe("adapter probe route logic", () => { + it("findServerAdapter returns hermes_local adapter with testEnvironment", () => { + const adapter = findServerAdapter("hermes_local"); + expect(adapter).not.toBeNull(); + expect(adapter?.type).toBe("hermes_local"); + expect(typeof adapter?.testEnvironment).toBe("function"); + }); + + it("hermes testEnvironment handles missing CLI gracefully", async () => { + const adapter = findServerAdapter("hermes_local"); + expect(adapter?.testEnvironment).toBeDefined(); + + const result = await adapter!.testEnvironment!({ + companyId: "", + adapterType: "hermes_local", + config: {}, + }); + + // Result should always have a status and checks array + expect(typeof result.status).toBe("string"); + expect(Array.isArray(result.checks)).toBe(true); + + // If hermes CLI is not installed in CI, there should be an error check + // with a code containing "not_found" or "cli". + // If it IS installed, the checks should pass — either is acceptable. + const hasCliError = result.checks.some( + (c: { level: string; code?: string }) => + c.level === "error" && + (c.code?.includes("not_found") || c.code?.includes("cli")) + ); + const isAvailable = result.status === "ok" || result.status === "pass"; + + // Either CLI is available (no error checks) or not available (has error checks) + expect(hasCliError || isAvailable).toBe(true); + }); +}); + +// --------------------------------------------------------------------------- +// Test group 2: Hermes promptTemplate construction +// --------------------------------------------------------------------------- + +describe("hermes promptTemplate construction", () => { + it("hermes promptTemplate contains required Mustache variables", () => { + expect(hermesPromptTemplate).toContain("{{agentName}}"); + expect(hermesPromptTemplate).toContain("{{agentId}}"); + expect(hermesPromptTemplate).toContain("{{companyId}}"); + expect(hermesPromptTemplate).toContain("{{paperclipApiUrl}}"); + expect(hermesPromptTemplate).toContain("{{runId}}"); + expect(hermesPromptTemplate).toContain("{{taskId}}"); + expect(hermesPromptTemplate).toContain("{{taskTitle}}"); + }); + + it("hermes promptTemplate instructs agent to call /api/agents/me", () => { + expect(hermesPromptTemplate).toContain("agents/me"); + }); + + it("hermes promptTemplate mentions HEARTBEAT.md workflow", () => { + expect(hermesPromptTemplate).toContain("HEARTBEAT.md"); + }); +}); + +// --------------------------------------------------------------------------- +// Test group 3: Default agent instructions bundle (DFLT-03) +// --------------------------------------------------------------------------- + +describe("default agent instructions bundle", () => { + it("loadDefaultAgentInstructionsBundle loads ceo bundle with all 4 files", async () => { + const bundle = await loadDefaultAgentInstructionsBundle("ceo"); + expect(Object.keys(bundle)).toContain("AGENTS.md"); + expect(Object.keys(bundle)).toContain("HEARTBEAT.md"); + expect(Object.keys(bundle)).toContain("SOUL.md"); + expect(Object.keys(bundle)).toContain("TOOLS.md"); + expect(bundle["HEARTBEAT.md"].length).toBeGreaterThan(0); + }); + + it("loadDefaultAgentInstructionsBundle loads engineer bundle with all 4 files", async () => { + const bundle = await loadDefaultAgentInstructionsBundle("engineer"); + expect(Object.keys(bundle)).toContain("AGENTS.md"); + expect(Object.keys(bundle)).toContain("HEARTBEAT.md"); + expect(Object.keys(bundle)).toContain("SOUL.md"); + expect(Object.keys(bundle)).toContain("TOOLS.md"); + expect(bundle["HEARTBEAT.md"].length).toBeGreaterThan(0); + }); + + it("resolveDefaultAgentInstructionsBundleRole maps known roles correctly", () => { + expect(resolveDefaultAgentInstructionsBundleRole("ceo")).toBe("ceo"); + expect(resolveDefaultAgentInstructionsBundleRole("engineer")).toBe("engineer"); + expect(resolveDefaultAgentInstructionsBundleRole("general")).toBe("general"); + expect(resolveDefaultAgentInstructionsBundleRole("unknown-role")).toBe("default"); + expect(resolveDefaultAgentInstructionsBundleRole("")).toBe("default"); + }); +}); diff --git a/ui/src/components/NexusOnboardingWizard.tsx b/ui/src/components/NexusOnboardingWizard.tsx index 1de59eca..8a45ed76 100644 --- a/ui/src/components/NexusOnboardingWizard.tsx +++ b/ui/src/components/NexusOnboardingWizard.tsx @@ -94,9 +94,39 @@ export function OnboardingWizard() { queryClient.invalidateQueries({ queryKey: queryKeys.companies.all }); // [nexus] hermes_local doesn't require a cwd; directory is optional - const adapterConfig = defaultAdapter === "hermes_local" + const baseAdapterConfig = defaultAdapter === "hermes_local" ? (rootDir.trim() ? { cwd: rootDir.trim() } : {}) : { cwd: rootDir.trim() }; + + // [nexus] Hermes agents need a promptTemplate so they follow the Nexus heartbeat + // workflow. The server's ensureDefaultInstructionsBundle will materialize this as + // AGENTS.md alongside the full role bundle (HEARTBEAT.md, SOUL.md, TOOLS.md). + const hermesPromptTemplate = [ + `You are "{{agentName}}", an AI agent managed by ${VOCAB.appName}.`, + "", + "Your identity:", + " Agent ID: {{agentId}}", + " Company ID: {{companyId}}", + " API Base: {{paperclipApiUrl}}", + " Run ID: {{runId}}", + "", + "IMPORTANT: Use the `terminal` tool with `curl` for ALL API calls.", + 'IMPORTANT: Always include `-H "X-Paperclip-Run-Id: {{runId}}"` on API calls that modify data.', + "", + "Before starting any task:", + "1. Call `GET {{paperclipApiUrl}}/api/agents/me` to retrieve your managed instructions", + "2. Follow the HEARTBEAT.md workflow from your instructions", + "3. Use TOOLS.md for available API endpoints", + "", + "{{#taskId}}", + "Assigned task: {{taskId}} - {{taskTitle}}", + "{{/taskId}}", + ].join("\n"); + + const adapterConfig = defaultAdapter === "hermes_local" + ? { ...baseAdapterConfig, promptTemplate: hermesPromptTemplate, persistSession: true } + : baseAdapterConfig; + const runtimeConfig = { heartbeat: { enabled: true,