From aeaeebea6254c2f79bc53eb233036799985b6dc5 Mon Sep 17 00:00:00 2001 From: delivery engine Date: Sun, 4 Oct 2026 21:17:17 +0100 Subject: [PATCH 1/3] Orchestrator mode falls back to development even when development is not offered Fixed Orchestrator fallback to use offered teams and added loaded-list availability guards, visible messages, and regression tests. Task task-9169e8495d, revision 2, run run-7cc819d450. --- .../OrchestratorComposerControls.test.tsx | 177 ++++++++++++++++++ .../delivery/OrchestratorComposerControls.tsx | 76 ++++++-- apps/web/src/state/delivery.test.ts | 74 +++++++- apps/web/src/state/delivery.ts | 14 +- 4 files changed, 315 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/components/delivery/OrchestratorComposerControls.test.tsx diff --git a/apps/web/src/components/delivery/OrchestratorComposerControls.test.tsx b/apps/web/src/components/delivery/OrchestratorComposerControls.test.tsx new file mode 100644 index 000000000000..0333d50313e8 --- /dev/null +++ b/apps/web/src/components/delivery/OrchestratorComposerControls.test.tsx @@ -0,0 +1,177 @@ +import { EnvironmentId } from "@t3tools/contracts"; +import { act } from "react"; +import { create, type ReactTestRenderer } from "react-test-renderer"; +import { afterEach, beforeEach, expect, it, vi } from "vite-plus/test"; +import { useDeliveryDraftStore, useOrchestratorDraftStore } from "../../state/delivery"; +import { OrchestratorComposerControls } from "./OrchestratorComposerControls"; + +const mock = vi.hoisted(() => ({ + reading: { body: null as unknown, error: null as string | null }, + action: vi.fn(), + navigate: vi.fn(), +})); +vi.mock("@tanstack/react-router", () => ({ useNavigate: () => mock.navigate })); +vi.mock("../../state/delivery", async (original) => ({ + ...(await original()), + useDeliveryRead: () => mock.reading, + useDeliveryAct: () => mock.action, +})); +vi.mock("./OrchestratorPanel", () => ({ useFlowPreview: () => null })); +vi.mock("../ui/select", () => ({ + Select: "select-root", + SelectTrigger: "select-trigger", + SelectValue: "output", + SelectPopup: "select-popup", + SelectItem: "select-item", +})); +vi.mock("../ui/popover", () => ({ + Popover: "popover-root", + PopoverTrigger: "popover-trigger", + PopoverPopup: "popover-popup", + PopoverTitle: "popover-title", +})); +vi.mock("./InfoPopover", () => ({ InfoPopover: "info-popover" })); +vi.mock("./SeatSettingsPanel", () => ({ SeatSettingsPanel: "seat-settings" })); +vi.mock("./SetupPanel", () => ({ SetupPanel: "setup-panel" })); +vi.mock("./TeamDefaultsDialog", () => ({ TeamDefaultsDialog: "team-defaults" })); +const store = () => useOrchestratorDraftStore.getState(); +const teams = ["rnd", "alpha"].map((team) => ({ team, available: true, seats: [], settings: [] })); +let renderer: ReactTestRenderer | undefined; +const view = () => ( + {}} + /> +); +async function render() { + await act(() => { + if (renderer) renderer.update(view()); + else renderer = create(view()); + }); +} +const alert = () => + renderer!.root + .findAllByProps({ role: "alert" }) + .map((node) => node.children.join("")) + .join(" "); +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + useDeliveryDraftStore.setState({ choices: {}, inherited: {}, remembered: {} }); + useOrchestratorDraftStore.setState({ + drafts: {}, + activity: {}, + saveRequests: {}, + startRequests: {}, + }); + mock.reading = { body: null, error: null }; + mock.navigate.mockReset(); + mock.action.mockReset().mockImplementation(async (path: string, body: { team?: string }) => { + if (path === "/api/tasks" && !teams.some((team) => team.team === body.team)) + return { ok: false, problems: ["unknown team"] }; + return { ok: true, body: { id: "task-1", card: {} } }; + }); +}); +afterEach(async () => { + await act(() => renderer?.unmount()); + renderer = undefined; + vi.unstubAllGlobals(); +}); + +it.each([null, "gone"])( + "starts with the first offered team (remembered %s)", + async (remembered) => { + if (remembered) useDeliveryDraftStore.getState().rememberTeam("env-a", remembered); + store().enter("t1", "env-a"); + await render(); + await act(() => store().requestStart("t1")); + expect(mock.action).not.toHaveBeenCalled(); + expect(store().activity.t1?.blocked).toBe("Teams are still loading."); + mock.reading = { body: teams, error: null }; + await render(); + expect(mock.action).not.toHaveBeenCalled(); + expect( + renderer!.root.findByProps({ "aria-label": "Team" }).findByType("output").children, + ).toEqual(["rnd"]); + const navigated = new Promise((resolve) => + mock.navigate.mockImplementation(() => resolve()), + ); + await act(async () => { + store().requestStart("t1"); + }); + await navigated; + expect(mock.action).toHaveBeenCalledWith( + "/api/tasks", + expect.objectContaining({ team: "rnd", draft: true }), + ); + expect(mock.action).toHaveBeenCalledWith("/api/tasks/task-1/submit", expect.anything()); + }, +); + +it.each([{ body: [] }, { body: [{ team: "triage", available: true }] }])( + "blocks empty selectable lists before any press", + async ({ body }) => { + store().enter("t1", "env-a"); + await render(); + mock.reading = { body, error: null }; + await render(); + expect(store().drafts.t1).not.toHaveProperty("awaitingTeams"); + expect(alert()).toBe("No teams are available to choose from."); + expect(store().activity.t1?.blocked).toBe(alert()); + await act(() => { + store().requestStart("t1"); + store().requestSave("t1"); + }); + expect(mock.action).not.toHaveBeenCalled(); + expect(renderer!.root.findAllByType("p").flatMap((node) => node.children)).not.toContain( + "Reading the team.", + ); + }, +); + +it.each([null, "saved-task"])( + "preserves a missing choice and blocks create or edit (%s)", + async (engineThread) => { + store().enter("t1", "env-a"); + store().chooseTeam("t1", "env-a", "missing"); + if (engineThread) store().update("t1", { engineThread }); + await render(); + expect(alert()).toBe(""); + mock.reading = { body: teams, error: null }; + await render(); + await act(() => { + store().requestStart("t1"); + store().requestSave("t1"); + }); + expect(store().drafts.t1?.team).toBe("missing"); + expect(alert()).toBe("Team missing is not offered here. Choose another team."); + expect(store().activity.t1?.blocked).toBe(alert()); + expect(mock.action).not.toHaveBeenCalled(); + await act(() => store().chooseTeam("t1", "env-a", "alpha")); + expect(alert()).toBe(""); + }, +); + +it("keeps read errors distinct from empty lists", async () => { + store().enter("t1", "env-a"); + mock.reading = { body: null, error: "offline" }; + await render(); + await act(() => store().requestStart("t1")); + expect(alert()).toBe("Delivery engine not reachable. offline"); + expect(mock.action).not.toHaveBeenCalled(); +}); + +it("does not skip an ineligible first offered team", async () => { + store().enter("t1", "env-a"); + mock.reading = { + body: [{ ...teams[0], available: false, why: "Needs setup" }, teams[1]], + error: null, + }; + await render(); + expect(store().drafts.t1?.team).toBe("rnd"); + expect(store().activity.t1?.blocked).toBeTruthy(); + await act(() => store().requestStart("t1")); + expect(alert()).toBe(store().activity.t1?.blocked); + expect(mock.action).not.toHaveBeenCalled(); +}); diff --git a/apps/web/src/components/delivery/OrchestratorComposerControls.tsx b/apps/web/src/components/delivery/OrchestratorComposerControls.tsx index 039cb62b8742..02e145e6186a 100644 --- a/apps/web/src/components/delivery/OrchestratorComposerControls.tsx +++ b/apps/web/src/components/delivery/OrchestratorComposerControls.tsx @@ -68,7 +68,7 @@ export function OrchestratorComposerControls(props: { const teamsLoaded = teamListStatus(teamsRead) === "loaded"; const awaitingTeams = draft?.awaitingTeams === true; // A new draft starts from the remembered team before the teams are read. Once - // they are, a team that is gone gives way to the default team, one time. + // they are, prefer development if offered, else the first offered team, one time. useEffect(() => { if (!awaitingTeams || !teamsLoaded) return; reconcileTeam( @@ -84,6 +84,14 @@ export function OrchestratorComposerControls(props: { const [editingDefaults, setEditingDefaults] = useState(false); const team = teams.find((candidate) => candidate.team === draft?.team) ?? null; + const teamAvailabilityReason = + teamsLoaded && !awaitingTeams + ? teams.length === 0 + ? "No teams are available to choose from." + : !team + ? `Team ${draft?.team} is not offered here. Choose another team.` + : null + : null; const flow = flowFor(team, draft?.workflow ?? null); const text = props.prompt.trim(); const seatsToSend = useMemo( @@ -107,24 +115,37 @@ export function OrchestratorComposerControls(props: { ? `Delivery engine not reachable. ${teamsRead.error}` : awaitingTeams ? "Teams are still loading." - : team && teamTaskBlock(team, flow) - ? teamTaskBlock(team, flow) - : chosen && chosen.problems.length > 0 - ? chosen.problems.join(" ") - : text.length === 0 - ? "Write what the team is asked to do." - : null; + : teamAvailabilityReason + ? teamAvailabilityReason + : team && teamTaskBlock(team, flow) + ? teamTaskBlock(team, flow) + : chosen && chosen.problems.length > 0 + ? chosen.problems.join(" ") + : text.length === 0 + ? "Write what the team is asked to do." + : null; useEffect(() => { setActivity(props.threadId, { busy, blocked, awaitingTeams, + teamAvailabilityReason, team: draft?.team ?? null, saved, flow, }); - }, [awaitingTeams, blocked, busy, draft?.team, flow, props.threadId, saved, setActivity]); + }, [ + awaitingTeams, + blocked, + busy, + draft?.team, + flow, + props.threadId, + saved, + setActivity, + teamAvailabilityReason, + ]); const save = useCallback(async (): Promise => { if (!draft) return null; @@ -165,15 +186,16 @@ export function OrchestratorComposerControls(props: { setProblems(["Teams are still loading."]); return; } + if (teamAvailabilityReason) return; void once("save", async () => { await save(); }); - }, [awaitingTeams, once, save, text.length]); + }, [awaitingTeams, once, save, text.length, teamAvailabilityReason]); const { onPromptCleared, threadId } = props; const onStart = useCallback(() => { if (blocked) { - setProblems([blocked]); + if (!teamAvailabilityReason) setProblems([blocked]); return; } void once("start", async () => { @@ -189,7 +211,18 @@ export function OrchestratorComposerControls(props: { // What is written next is written on the task, in its own composer. void navigate({ to: "/board", search: { task: id } }); }); - }, [act, blocked, leave, navigate, once, onPromptCleared, person, save, threadId]); + }, [ + act, + blocked, + leave, + navigate, + once, + onPromptCleared, + person, + save, + threadId, + teamAvailabilityReason, + ]); // The buttons stand in the composer's own place for Send, and ask from there. const handledSave = useRef(saveRequest); @@ -214,11 +247,12 @@ export function OrchestratorComposerControls(props: { >