mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 02:44:50 +01:00
Ports the substance of #11259, which predated the recent onboarding work and was still open. One of the parts it solves is a regression #11352 introduced. A company that already has its mission opens on the agent step, which is the point of #11352. What that missed is that the mission field is filled only by the step being skipped, and that the field is not decoration: `composeCeoInstructions` seeds the lead agent's instructions from it, and the Review checklist reads it. So every Cloud-seeded company hired its lead agent with no Mission line at all, having been routed there precisely *because* it had a mission. Before #11352 those companies dead-ended on the mission step; a dead end became a quiet data loss, which is worse, because it completes. `selectExistingCompanyMission` reads the company's own goal back into the shape the mission field holds, and the wizard hydrates from it - only when the field is empty, so a customer editing their mission is never overwritten by the stored copy. The marker recording that hydration travels with the field it describes, cleared wherever `companyGoal` is. `isExistingCompanyMissionUnresolved` holds the hire while that read is outstanding, counting an in-flight refetch over cached goals as unresolved. That is the rule #11382 settled on a day earlier for a different consumer - `isFetching`, not `isLoading`, because retained data is not an answer to the question being asked now. #11259 had it first, on 11 August. `canGoBackFromOnboardingStep` and `canJumpToOnboardingStep` bound how far back a run can walk by the step it entered on. The Back button already applied that rule inline; the progress bar applied only the "already completed" half, so a run holding a company could still jump to step 1 - the step whose job is to create one. The entry step is captured once, when the wizard opens, for the same reason the step itself is. `planMissionPersistence` came with them and turned out to be required rather than tidying. Hydration sets `createdCompanyGoalId` from the company's existing goal, and confirming the mission read that id as "already written" and skipped the write, discarding the customer's edit. That skip was safe only while the id could arrive one way - by writing. A goal in hand now means update it. Each piece was checked by removing it and confirming a specific case fails. The hydration case asserts on `saveInstructionsFile`'s content, the actual consumer, rather than on the mission textarea, because the entry path never renders that field and the navigation bound now prevents reaching it. One caveat recorded rather than smoothed over: the reopen case fails only when both marker-clears are removed, since `reset()` also clears the company id and the next introduction routes through `clearCompanyScopedState`. They are kept as one invariant rather than one guard plus a coincidence. ui typecheck clean; full ui suite 4004 pass. Two failures remain, in IssueProperties and StatusCards/format; both are date-dependent, both reproduce on master with these changes stashed, and neither file is touched here. Co-Authored-By: Jannes Stubbemann <stubbi@users.noreply.github.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
154 lines
4.6 KiB
TypeScript
154 lines
4.6 KiB
TypeScript
// @vitest-environment jsdom
|
|
|
|
import { flushSync } from "react-dom";
|
|
import { act } from "react";
|
|
import { createRoot, type Root } from "react-dom/client";
|
|
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { Goal } from "@paperclipai/shared";
|
|
import { useCompanyMission } from "./useCompanyMission";
|
|
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
|
|
|
const mockGoalsApi = vi.hoisted(() => ({ list: vi.fn() }));
|
|
|
|
vi.mock("../api/goals", () => ({ goalsApi: mockGoalsApi }));
|
|
|
|
function companyGoal(id: string): Goal {
|
|
return {
|
|
id,
|
|
companyId: "company-1",
|
|
title: "Ship the thing",
|
|
description: null,
|
|
level: "company",
|
|
status: "active",
|
|
parentId: null,
|
|
ownerAgentId: null,
|
|
createdAt: new Date("2026-03-02T00:00:00Z"),
|
|
updatedAt: new Date("2026-03-02T00:00:00Z"),
|
|
} as Goal;
|
|
}
|
|
|
|
let captured: ReturnType<typeof useCompanyMission> | null = null;
|
|
|
|
function Harness({ companyId }: { companyId: string | null }) {
|
|
captured = useCompanyMission(companyId);
|
|
return null;
|
|
}
|
|
|
|
describe("useCompanyMission", () => {
|
|
let container: HTMLDivElement;
|
|
let queryClient: QueryClient;
|
|
let root: Root | null = null;
|
|
|
|
function render(companyId: string | null) {
|
|
root = createRoot(container);
|
|
flushSync(() => {
|
|
root!.render(
|
|
<QueryClientProvider client={queryClient}>
|
|
<Harness companyId={companyId} />
|
|
</QueryClientProvider>,
|
|
);
|
|
});
|
|
}
|
|
|
|
// React Query resolves through microtasks and React schedules the re-render
|
|
// after them, so a single tick is not reliably enough under load. Drain
|
|
// until the hook reports an answer rather than guessing at a tick count.
|
|
async function settle() {
|
|
for (let i = 0; i < 50 && !captured?.settled; i++) {
|
|
await act(async () => {
|
|
await new Promise((resolve) => setTimeout(resolve, 0));
|
|
});
|
|
}
|
|
}
|
|
|
|
beforeEach(() => {
|
|
captured = null;
|
|
container = document.createElement("div");
|
|
document.body.appendChild(container);
|
|
queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
|
});
|
|
|
|
afterEach(() => {
|
|
flushSync(() => root?.unmount());
|
|
root = null;
|
|
queryClient.clear();
|
|
container.remove();
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
it("withholds an answer while the lookup is in flight", () => {
|
|
mockGoalsApi.list.mockReturnValue(new Promise(() => {}));
|
|
render("company-1");
|
|
|
|
expect(captured).toEqual({
|
|
hasMission: undefined,
|
|
settled: false,
|
|
mission: { goalId: null, goalInput: "" },
|
|
fetching: true,
|
|
});
|
|
});
|
|
|
|
it("reports a mission when the company has a company-level goal", async () => {
|
|
mockGoalsApi.list.mockResolvedValue([companyGoal("goal-1")]);
|
|
render("company-1");
|
|
await settle();
|
|
|
|
// The mission comes back in the shape the wizard's textarea holds, so the
|
|
// agent step can seed the lead agent's instructions from it.
|
|
expect(captured).toEqual({
|
|
hasMission: true,
|
|
settled: true,
|
|
mission: { goalId: "goal-1", goalInput: "Ship the thing" },
|
|
fetching: false,
|
|
});
|
|
});
|
|
|
|
it("reports no mission when the company has no company-level goal", async () => {
|
|
mockGoalsApi.list.mockResolvedValue([]);
|
|
render("company-1");
|
|
await settle();
|
|
|
|
expect(captured).toEqual({
|
|
hasMission: false,
|
|
settled: true,
|
|
mission: { goalId: null, goalInput: "" },
|
|
fetching: false,
|
|
});
|
|
});
|
|
|
|
it("settles with an unknown mission when the lookup fails", async () => {
|
|
// The fail-open rule. Waiting for the data itself would leave `settled`
|
|
// false forever after a request exhausts its retries, and every caller
|
|
// gates opening onboarding on it — an agentless company would then get no
|
|
// onboarding at all, which is worse than being asked for its mission
|
|
// twice.
|
|
mockGoalsApi.list.mockRejectedValue(new Error("goals unavailable"));
|
|
render("company-1");
|
|
await settle();
|
|
|
|
expect(captured).toEqual({
|
|
hasMission: undefined,
|
|
settled: true,
|
|
mission: { goalId: null, goalInput: "" },
|
|
fetching: false,
|
|
});
|
|
});
|
|
|
|
it("settles immediately when there is no company to ask about", () => {
|
|
// A disabled query stays pending forever. Reading that as "still loading"
|
|
// is the same failure as above, reached without a request.
|
|
render(null);
|
|
|
|
expect(captured).toEqual({
|
|
hasMission: undefined,
|
|
settled: true,
|
|
mission: { goalId: null, goalInput: "" },
|
|
fetching: false,
|
|
});
|
|
expect(mockGoalsApi.list).not.toHaveBeenCalled();
|
|
});
|
|
});
|