feat: add company builder wizard with AI-powered org generation (#73)
* feat: add company builder wizard with AI-powered org generation Adds a new "Build Your Company" step to the onboarding wizard that lets users describe their business and generates a full agent org structure using OpenClaw's AI. Includes company plan generation, role deduplication, agent bootstrap with main-agent reuse, org chart preview, confetti on success, CSS voxel running-avatar loader, amber theme unification, and best-effort SSH workspace cleanup. Made-with: Cursor * fix: resolve lint errors in CompanyBuilderModal Replace setState-in-effect pattern with a direct callback, escape apostrophes in JSX text, and derive org chart hover state without side effects. Made-with: Cursor --------- Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
@@ -0,0 +1,310 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { runCompanyBootstrapOperation } from "@/features/company-builder/operations/companyBootstrapOperation";
|
||||
import { parseCompanyPlanFromAssistantText } from "@/features/company-builder/planning";
|
||||
|
||||
describe("runCompanyBootstrapOperation", () => {
|
||||
it("creates agents, reloads the fleet, applies permissions, and persists the snapshot", async () => {
|
||||
const plan = parseCompanyPlanFromAssistantText(
|
||||
JSON.stringify({
|
||||
companyName: "Launch Lab",
|
||||
summary: "A product org with engineering and QA.",
|
||||
sharedRules: [],
|
||||
plannerNotes: [],
|
||||
roles: [
|
||||
{
|
||||
title: "Engineer",
|
||||
purpose: "Builds features.",
|
||||
soul: "Fast but careful.",
|
||||
responsibilities: ["Ship features"],
|
||||
collaborators: ["QA Lead"],
|
||||
tools: ["repo"],
|
||||
heartbeat: ["Check PRs"],
|
||||
emoji: "🛠",
|
||||
creature: "otter",
|
||||
vibe: "focused",
|
||||
userContext: "",
|
||||
commandMode: "auto",
|
||||
},
|
||||
{
|
||||
title: "QA Lead",
|
||||
purpose: "Protects quality.",
|
||||
soul: "Detail-oriented.",
|
||||
responsibilities: ["Review releases"],
|
||||
collaborators: ["Engineer"],
|
||||
tools: ["test plans"],
|
||||
heartbeat: ["Review regressions"],
|
||||
emoji: "🧪",
|
||||
creature: "owl",
|
||||
vibe: "precise",
|
||||
userContext: "",
|
||||
commandMode: "ask",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const createAgent = vi
|
||||
.fn<(name: string) => Promise<{ id: string }>>()
|
||||
.mockResolvedValueOnce({ id: "engineer" })
|
||||
.mockResolvedValueOnce({ id: "qa-lead" });
|
||||
const writeAgentFiles = vi.fn<(agentId: string, files: Record<string, string>) => Promise<void>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const saveAvatar = vi.fn<(agentId: string) => void>();
|
||||
const loadAgents = vi.fn<() => Promise<void>>().mockResolvedValue(undefined);
|
||||
const findAgentById = vi.fn((agentId: string) => ({
|
||||
agentId,
|
||||
sessionKey: `agent:${agentId}:main`,
|
||||
}));
|
||||
const applyPermissions = vi
|
||||
.fn<(agentId: string, sessionKey: string, commandMode: "off" | "ask" | "auto") => Promise<void>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const resetAgentSession = vi
|
||||
.fn<(agentId: string, sessionKey: string) => Promise<void>>()
|
||||
.mockResolvedValue(undefined);
|
||||
const persistSnapshot = vi.fn<(input: { businessDescription: string; improvedBrief: string }, planArg: typeof plan) => void>();
|
||||
const setOfficeTitle = vi.fn<(title: string) => void>();
|
||||
const selectAgent = vi.fn<(agentId: string) => void>();
|
||||
const setStatusLine = vi.fn<(value: string | null) => void>();
|
||||
|
||||
const createdIds = await runCompanyBootstrapOperation({
|
||||
input: {
|
||||
businessDescription: "Build a launch team.",
|
||||
improvedBrief: "Build a launch team with engineering and QA.",
|
||||
},
|
||||
plan,
|
||||
existingAgentIds: [],
|
||||
createAgent,
|
||||
writeAgentFiles,
|
||||
saveAvatar,
|
||||
loadAgents,
|
||||
findAgentById,
|
||||
resetAgentSession,
|
||||
applyPermissions,
|
||||
persistSnapshot,
|
||||
setOfficeTitle,
|
||||
selectAgent,
|
||||
setStatusLine,
|
||||
});
|
||||
|
||||
expect(createdIds).toEqual(["engineer", "qa-lead"]);
|
||||
expect(createAgent).toHaveBeenCalledTimes(2);
|
||||
expect(writeAgentFiles).toHaveBeenCalledTimes(2);
|
||||
expect(saveAvatar).toHaveBeenCalledTimes(2);
|
||||
expect(loadAgents).toHaveBeenCalledTimes(1);
|
||||
expect(applyPermissions).toHaveBeenNthCalledWith(
|
||||
1,
|
||||
"engineer",
|
||||
"agent:engineer:main",
|
||||
"auto",
|
||||
);
|
||||
expect(applyPermissions).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"qa-lead",
|
||||
"agent:qa-lead:main",
|
||||
"ask",
|
||||
);
|
||||
expect(setOfficeTitle).toHaveBeenCalledWith("Launch Lab HQ");
|
||||
expect(selectAgent).toHaveBeenCalledWith("engineer");
|
||||
expect(persistSnapshot).toHaveBeenCalledOnce();
|
||||
expect(resetAgentSession).not.toHaveBeenCalled();
|
||||
expect(setStatusLine).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
|
||||
it("deletes existing agents before creating the new company", async () => {
|
||||
const plan = parseCompanyPlanFromAssistantText(
|
||||
JSON.stringify({
|
||||
companyName: "Fresh Start Studio",
|
||||
summary: "A brand new company plan.",
|
||||
sharedRules: [],
|
||||
plannerNotes: [],
|
||||
roles: [
|
||||
{
|
||||
title: "Creative Lead",
|
||||
purpose: "Leads delivery.",
|
||||
soul: "Bold.",
|
||||
responsibilities: ["Direct projects"],
|
||||
collaborators: [],
|
||||
tools: [],
|
||||
heartbeat: [],
|
||||
emoji: "🎨",
|
||||
creature: "fox",
|
||||
vibe: "sharp",
|
||||
userContext: "",
|
||||
commandMode: "ask",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const order: string[] = [];
|
||||
await runCompanyBootstrapOperation({
|
||||
input: {
|
||||
businessDescription: "Replace my current team.",
|
||||
improvedBrief: "Replace my current team with a new creative studio.",
|
||||
},
|
||||
plan,
|
||||
existingAgentIds: ["old-a", "old-b"],
|
||||
deleteExistingAgent: async (agentId) => {
|
||||
order.push(`delete:${agentId}`);
|
||||
},
|
||||
clearReusedAgentState: async () => {
|
||||
order.push("clear-reused");
|
||||
},
|
||||
onExistingAgentDeleted: (agentId) => {
|
||||
order.push(`deleted:${agentId}`);
|
||||
},
|
||||
createAgent: async (name) => {
|
||||
order.push(`create:${name}`);
|
||||
return { id: "creative-lead" };
|
||||
},
|
||||
writeAgentFiles: async () => {
|
||||
order.push("write");
|
||||
},
|
||||
saveAvatar: () => {
|
||||
order.push("avatar");
|
||||
},
|
||||
loadAgents: async () => {
|
||||
order.push("load");
|
||||
},
|
||||
findAgentById: () => ({
|
||||
agentId: "creative-lead",
|
||||
sessionKey: "agent:creative-lead:main",
|
||||
}),
|
||||
resetAgentSession: async () => {
|
||||
order.push("reset");
|
||||
},
|
||||
applyPermissions: async () => {
|
||||
order.push("permissions");
|
||||
},
|
||||
persistSnapshot: () => {
|
||||
order.push("persist");
|
||||
},
|
||||
setOfficeTitle: () => {
|
||||
order.push("title");
|
||||
},
|
||||
selectAgent: () => {
|
||||
order.push("select");
|
||||
},
|
||||
setStatusLine: () => {},
|
||||
});
|
||||
|
||||
expect(order.slice(0, 4)).toEqual([
|
||||
"delete:old-a",
|
||||
"deleted:old-a",
|
||||
"delete:old-b",
|
||||
"deleted:old-b",
|
||||
]);
|
||||
expect(order).not.toContain("clear-reused");
|
||||
expect(order).toContain("create:CreativeLead");
|
||||
expect(order).not.toContain("reset");
|
||||
});
|
||||
|
||||
it("reuses main as the first company role instead of deleting it", async () => {
|
||||
const plan = parseCompanyPlanFromAssistantText(
|
||||
JSON.stringify({
|
||||
companyName: "Fresh Start Studio",
|
||||
summary: "A brand new company plan.",
|
||||
sharedRules: [],
|
||||
plannerNotes: [],
|
||||
roles: [
|
||||
{
|
||||
title: "Creative Lead",
|
||||
purpose: "Leads delivery.",
|
||||
soul: "Bold.",
|
||||
responsibilities: ["Direct projects"],
|
||||
collaborators: [],
|
||||
tools: [],
|
||||
heartbeat: [],
|
||||
emoji: "🎨",
|
||||
creature: "fox",
|
||||
vibe: "sharp",
|
||||
userContext: "",
|
||||
commandMode: "ask",
|
||||
},
|
||||
{
|
||||
title: "Operator",
|
||||
purpose: "Runs operations.",
|
||||
soul: "Calm.",
|
||||
responsibilities: ["Keep work moving"],
|
||||
collaborators: [],
|
||||
tools: [],
|
||||
heartbeat: [],
|
||||
emoji: "⚙️",
|
||||
creature: "owl",
|
||||
vibe: "steady",
|
||||
userContext: "",
|
||||
commandMode: "auto",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const order: string[] = [];
|
||||
const createdIds = await runCompanyBootstrapOperation({
|
||||
input: {
|
||||
businessDescription: "Replace my current team.",
|
||||
improvedBrief: "Replace my current team with a new creative studio.",
|
||||
},
|
||||
plan,
|
||||
existingAgentIds: ["main", "old-b"],
|
||||
deleteExistingAgent: async (agentId) => {
|
||||
order.push(`delete:${agentId}`);
|
||||
},
|
||||
clearReusedAgentState: async (agentId) => {
|
||||
order.push(`clear-reused:${agentId}`);
|
||||
},
|
||||
renameAgent: async (agentId, name) => {
|
||||
order.push(`rename:${agentId}:${name}`);
|
||||
},
|
||||
onExistingAgentDeleted: (agentId) => {
|
||||
order.push(`deleted:${agentId}`);
|
||||
},
|
||||
createAgent: async (name) => {
|
||||
order.push(`create:${name}`);
|
||||
return { id: "operator" };
|
||||
},
|
||||
writeAgentFiles: async (agentId) => {
|
||||
order.push(`write:${agentId}`);
|
||||
},
|
||||
saveAvatar: (agentId) => {
|
||||
order.push(`avatar:${agentId}`);
|
||||
},
|
||||
loadAgents: async () => {
|
||||
order.push("load");
|
||||
},
|
||||
findAgentById: (agentId) => ({
|
||||
agentId,
|
||||
sessionKey: `agent:${agentId}:main`,
|
||||
}),
|
||||
resetAgentSession: async (agentId, sessionKey) => {
|
||||
order.push(`reset:${agentId}:${sessionKey}`);
|
||||
},
|
||||
applyPermissions: async (agentId, _sessionKey, commandMode) => {
|
||||
order.push(`permissions:${agentId}:${commandMode}`);
|
||||
},
|
||||
persistSnapshot: () => {
|
||||
order.push("persist");
|
||||
},
|
||||
setOfficeTitle: () => {
|
||||
order.push("title");
|
||||
},
|
||||
selectAgent: (agentId) => {
|
||||
order.push(`select:${agentId}`);
|
||||
},
|
||||
setStatusLine: () => {},
|
||||
});
|
||||
|
||||
expect(order).not.toContain("delete:main");
|
||||
expect(order.slice(0, 2)).toEqual(["delete:old-b", "deleted:old-b"]);
|
||||
expect(order).toContain("clear-reused:main");
|
||||
expect(order).toContain("rename:main:CreativeLead");
|
||||
expect(order).toContain("write:main");
|
||||
expect(order).toContain("avatar:main");
|
||||
expect(order).toContain("create:Operator");
|
||||
expect(order).toContain("permissions:main:ask");
|
||||
expect(order).toContain("permissions:operator:auto");
|
||||
expect(order).toContain("reset:main:agent:main:main");
|
||||
expect(order).toContain("select:main");
|
||||
expect(createdIds).toEqual(["main", "operator"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,197 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildCompanyAgentBlueprints,
|
||||
buildStoredCompanySnapshot,
|
||||
parseCompanyPlanFromAssistantText,
|
||||
} from "@/features/company-builder/planning";
|
||||
|
||||
describe("parseCompanyPlanFromAssistantText", () => {
|
||||
it("parses fenced JSON into a normalized company plan", () => {
|
||||
const plan = parseCompanyPlanFromAssistantText(`
|
||||
\`\`\`json
|
||||
{
|
||||
"companyName": "Orbit Threads",
|
||||
"summary": "A silly but practical clothing brand.",
|
||||
"sharedRules": ["Keep handoffs explicit"],
|
||||
"plannerNotes": ["Stay brand-consistent"],
|
||||
"roles": [
|
||||
{
|
||||
"name": "StoreManager",
|
||||
"purpose": "Owns sales and operations.",
|
||||
"soul": "Decisive and upbeat.",
|
||||
"responsibilities": ["Run the shop floor", "Track promotions"],
|
||||
"collaborators": ["Social Media Stylist"],
|
||||
"tools": ["CRM", "inventory dashboard"],
|
||||
"heartbeat": ["Check sales every morning"],
|
||||
"emoji": "🧵",
|
||||
"creature": "fox",
|
||||
"vibe": "sharp and stylish",
|
||||
"userContext": "The founder wants weekly launches.",
|
||||
"commandMode": "ask"
|
||||
}
|
||||
]
|
||||
}
|
||||
\`\`\`
|
||||
`);
|
||||
|
||||
expect(plan.companyName).toBe("Orbit Threads");
|
||||
expect(plan.roles).toHaveLength(1);
|
||||
expect(plan.roles[0]?.title).toBe("StoreManager");
|
||||
expect(plan.roles[0]?.responsibilities).toEqual([
|
||||
"Run the shop floor",
|
||||
"Track promotions",
|
||||
]);
|
||||
});
|
||||
|
||||
it("normalizes multi-word role names into one word", () => {
|
||||
const plan = parseCompanyPlanFromAssistantText(
|
||||
JSON.stringify({
|
||||
companyName: "Orbit Threads",
|
||||
summary: "A silly but practical clothing brand.",
|
||||
roles: [
|
||||
{
|
||||
title: "Pipeline Orbit Captain",
|
||||
purpose: "Owns delivery.",
|
||||
soul: "Focused.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.roles[0]?.title).toBe("PipelineOrbitCapta");
|
||||
});
|
||||
|
||||
it("dedupes repeated generated role names", () => {
|
||||
const plan = parseCompanyPlanFromAssistantText(
|
||||
JSON.stringify({
|
||||
companyName: "Orbit Threads",
|
||||
summary: "A silly but practical clothing brand.",
|
||||
roles: [
|
||||
{
|
||||
name: "Builder",
|
||||
purpose: "Owns delivery.",
|
||||
soul: "Focused.",
|
||||
},
|
||||
{
|
||||
name: "Builder",
|
||||
purpose: "Owns quality.",
|
||||
soul: "Sharp.",
|
||||
},
|
||||
{
|
||||
name: "Builder",
|
||||
purpose: "Owns support.",
|
||||
soul: "Helpful.",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(plan.roles.map((role) => role.title)).toEqual(["Builder", "Builder2", "Builder3"]);
|
||||
expect(plan.roles.map((role) => role.id)).toEqual(["builder", "builder-2", "builder-3"]);
|
||||
});
|
||||
|
||||
it("throws when no roles are returned", () => {
|
||||
expect(() =>
|
||||
parseCompanyPlanFromAssistantText(
|
||||
JSON.stringify({
|
||||
companyName: "Empty Co",
|
||||
summary: "Missing roles.",
|
||||
roles: [],
|
||||
}),
|
||||
),
|
||||
).toThrow("did not return any company roles");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildCompanyAgentBlueprints", () => {
|
||||
it("maps roles into agent file blueprints", () => {
|
||||
const plan = parseCompanyPlanFromAssistantText(
|
||||
JSON.stringify({
|
||||
companyName: "Bug Bistro",
|
||||
summary: "A software team with strong handoffs.",
|
||||
sharedRules: ["Document decisions"],
|
||||
plannerNotes: ["Stay practical"],
|
||||
roles: [
|
||||
{
|
||||
name: "Developer",
|
||||
purpose: "Ships code.",
|
||||
soul: "Calm and methodical.",
|
||||
responsibilities: ["Implement features"],
|
||||
collaborators: ["QA Lead"],
|
||||
tools: ["repo", "tests"],
|
||||
heartbeat: ["Check PR queue"],
|
||||
emoji: "🛠",
|
||||
creature: "otter",
|
||||
vibe: "focused",
|
||||
userContext: "The company ships weekly.",
|
||||
commandMode: "auto",
|
||||
},
|
||||
{
|
||||
name: "QaLead",
|
||||
purpose: "Protects quality.",
|
||||
soul: "Skeptical and helpful.",
|
||||
responsibilities: ["Review releases"],
|
||||
collaborators: ["Developer"],
|
||||
tools: ["test plans"],
|
||||
heartbeat: ["Review risk daily"],
|
||||
emoji: "🧪",
|
||||
creature: "owl",
|
||||
vibe: "precise",
|
||||
userContext: "The company ships weekly.",
|
||||
commandMode: "ask",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const blueprints = buildCompanyAgentBlueprints(plan);
|
||||
|
||||
expect(blueprints).toHaveLength(2);
|
||||
expect(blueprints[0]?.files["AGENTS.md"]).toContain("Bug Bistro");
|
||||
expect(blueprints[0]?.files["AGENTS.md"]).toContain("Ships code.");
|
||||
expect(blueprints[0]?.files["SOUL.md"]).toContain("Calm and methodical.");
|
||||
expect(blueprints[1]?.files["HEARTBEAT.md"]).toContain("Review risk daily.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildStoredCompanySnapshot", () => {
|
||||
it("stores the serialized plan for reopening later", () => {
|
||||
const plan = parseCompanyPlanFromAssistantText(
|
||||
JSON.stringify({
|
||||
companyName: "Planner Corp",
|
||||
summary: "A generated company.",
|
||||
sharedRules: [],
|
||||
plannerNotes: [],
|
||||
roles: [
|
||||
{
|
||||
title: "Planner",
|
||||
purpose: "Plans work.",
|
||||
soul: "Structured.",
|
||||
responsibilities: ["Prioritize tasks"],
|
||||
collaborators: [],
|
||||
tools: [],
|
||||
heartbeat: [],
|
||||
emoji: "📋",
|
||||
creature: "cat",
|
||||
vibe: "organized",
|
||||
userContext: "",
|
||||
commandMode: "ask",
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
|
||||
const snapshot = buildStoredCompanySnapshot({
|
||||
prompt: "Build me a software company.",
|
||||
improvedBrief: "Build a software company with planning and delivery.",
|
||||
plan,
|
||||
now: () => "2026-03-26T00:00:00.000Z",
|
||||
});
|
||||
|
||||
expect(snapshot.companyName).toBe("Planner Corp");
|
||||
expect(snapshot.roleTitles).toEqual(["Planner"]);
|
||||
expect(JSON.parse(snapshot.planJson)).toMatchObject({
|
||||
companyName: "Planner Corp",
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -42,6 +42,13 @@ describe("getStepIndex", () => {
|
||||
const idx = ONBOARDING_STEPS.findIndex((s) => s.id === "connect");
|
||||
expect(getStepIndex("connect")).toBe(idx);
|
||||
});
|
||||
|
||||
it("includes the company step before completion", () => {
|
||||
const companyIndex = getStepIndex("company");
|
||||
const completeIndex = getStepIndex("complete");
|
||||
expect(companyIndex).toBeGreaterThan(getStepIndex("agents"));
|
||||
expect(companyIndex).toBe(completeIndex - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getNextStep", () => {
|
||||
|
||||
Reference in New Issue
Block a user