diff --git a/src/shared/ipc.transport.test.ts b/src/shared/ipc.transport.test.ts index 918b208d1..a7c5a96bd 100644 --- a/src/shared/ipc.transport.test.ts +++ b/src/shared/ipc.transport.test.ts @@ -395,6 +395,34 @@ describe("shared ipc transport seam", () => { ]); }); + it("preserves optional cross-project identity in camelCase envelopes", async () => { + const ipc = await import("./ipc"); + const fake = new FakeTransport(); + const agents = [{ name: "Role name", description: "", path: "D:/Source/.ac/_agent_one", + projectName: "Source", projectPath: "D:/Source", isLocal: false }]; + fake.resolve("list_all_agents", agents); + fake.resolve("create_team", undefined); + const restore = ipc.__setTransportForTests(fake); + try { + expect(await ipc.EntityAPI.listAllAgents(["D:/Source"], "C:/Destination")).toEqual(agents); + expect(fake.lastCall("list_all_agents")?.args).toEqual({ + projectPaths: ["D:/Source"], destinationProjectPath: "C:/Destination", + }); + await ipc.EntityAPI.listAllAgents(["D:/Source"]); + expect(fake.lastCall("list_all_agents")?.args).toEqual({ projectPaths: ["D:/Source"] }); + const sources = [{ agentRef: "_agent_one", sourceProjectPath: "D:/Source" }]; + await ipc.EntityAPI.createTeam("C:/Destination", "imported", ["_agent_one"], "_agent_one", [], [], sources); + expect(fake.lastCall("create_team")?.args).toEqual({ + projectPath: "C:/Destination", name: "imported", agents: ["_agent_one"], + coordinator: "_agent_one", repos: [], contextAlertPercentages: [], agentSources: sources, + }); + await ipc.EntityAPI.createTeam("C:/Destination", "empty", [], "", [], [], []); + expect(fake.lastCall("create_team")?.args.agentSources).toEqual([]); + } finally { + restore(); + } + }); + it("adapts only missing or undefined alert fields to fresh empty arrays", async () => { const ipc = await import("./ipc"); const fake = new FakeTransport(); diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index ca7d5ff14..5cbd90b8a 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -83,6 +83,8 @@ import type { UnresolvedLoopTarget, LoopUpdateInput, TeamConfigResult, + TeamAgentSource, + TeamWizardAgentEntry, WindowGeometry, TaskUpdateResult, TaskSnapshot, @@ -1548,10 +1550,10 @@ export const EntityAPI = { deleteAgentMatrix: (projectPath: string, agentPath: string) => transport.invoke("delete_agent_matrix", { projectPath, agentPath }), - listAllAgents: (projectPaths: string[]) => - transport.invoke<{ name: string; description: string; path: string; projectName: string }[]>( + listAllAgents: (projectPaths: string[], destinationProjectPath?: string) => + transport.invoke<(TeamWizardAgentEntry & { description: string })[]>( "list_all_agents", - { projectPaths } + { projectPaths, ...(destinationProjectPath !== undefined ? { destinationProjectPath } : {}) } ), createTeam: ( @@ -1561,6 +1563,7 @@ export const EntityAPI = { coordinator: string, repos: { url: string; agents: string[] }[], contextAlertPercentages: number[], + agentSources?: TeamAgentSource[], ) => transport.invoke("create_team", { projectPath, @@ -1569,6 +1572,7 @@ export const EntityAPI = { coordinator, repos, contextAlertPercentages, + ...(agentSources !== undefined ? { agentSources } : {}), }), deleteTeam: (projectPath: string, teamName: string) => diff --git a/src/shared/types.ts b/src/shared/types.ts index eb66c1065..3e8240b02 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -2001,10 +2001,17 @@ export interface AcProjectRefreshRequestedPayload { } +export interface TeamAgentSource { + agentRef: string; + sourceProjectPath: string; +} + export interface TeamWizardAgentEntry { name: string; path: string; projectName: string; + projectPath?: string; + isLocal?: boolean | null; } export interface TeamWizardRepoEntry { diff --git a/src/sidebar/components/NewTeamModal.context-alerts.test.tsx b/src/sidebar/components/NewTeamModal.context-alerts.test.tsx index 8f65a5824..b978c83a8 100644 --- a/src/sidebar/components/NewTeamModal.context-alerts.test.tsx +++ b/src/sidebar/components/NewTeamModal.context-alerts.test.tsx @@ -34,7 +34,7 @@ function deferred(): Deferred { function setupTransport(fake: FakeTransport): void { fake.resolve("list_all_agents", [ - { name: "dev-webpage-ui", path: agentPath, projectName: "Project" }, + { name: "dev-webpage-ui", path: agentPath, projectName: "Project", projectPath, isLocal: true }, ]); fake.resolve("create_team", undefined); fake.resolve("discover_project", discovery()); @@ -148,6 +148,7 @@ describe("NewTeamModal context alerts", () => { coordinator: "_agent_dev-webpage-ui", repos: method === "No repos" ? [] : [{ url: repoUrl, agents: ["_agent_dev-webpage-ui"] }], contextAlertPercentages: [], + agentSources: [{ agentRef: "_agent_dev-webpage-ui", sourceProjectPath: projectPath }], }, }, ]); diff --git a/src/sidebar/components/NewTeamModal.cross-project.test.tsx b/src/sidebar/components/NewTeamModal.cross-project.test.tsx new file mode 100644 index 000000000..9fc2db32e --- /dev/null +++ b/src/sidebar/components/NewTeamModal.cross-project.test.tsx @@ -0,0 +1,203 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import NewTeamModal from "./NewTeamModal"; +import { projectStore } from "../stores/project"; +import type { AcDiscoveryResult } from "../../shared/types"; +import { FakeTransport } from "../../shared/testing/fake-transport"; +import { click, discovery, input, installBrowserDomStubs, renderWithFakeTransport, + resetUiStoresForTests, waitFor } from "../../shared/testing/ui-harness"; + +const destination = "C:/Destination"; +const local = { name: "Copied role", path: `${destination}/.ac/_agent_local`, + projectName: "Destination", projectPath: destination, isLocal: true, + provenance: { copies: [{ sourceProjectName: "Historical" }] } }; +const foreign = { name: "Visible role", path: "D:/one/.ac/_agent_remote", + projectName: "Shared", projectPath: "D:/one", isLocal: false }; +const other = { name: "Other role", path: "E:/two/.ac/_agent_other", + projectName: "Shared", projectPath: "E:/two", isLocal: false }; + +describe("NewTeamModal cross-project identity", () => { + let rendered: ReturnType | undefined; + let restoreDom: () => void; + let fake: FakeTransport; + let close: ReturnType void>>; + beforeEach(() => { + restoreDom = installBrowserDomStubs(); + resetUiStoresForTests(); + fake = new FakeTransport(); + fake.resolve("create_team", undefined); + fake.resolve("discover_project", discovery()); + close = vi.fn(); + vi.spyOn(console, "error").mockImplementation(() => undefined); + }); + afterEach(() => { + rendered?.cleanup(); + restoreDom(); + resetUiStoresForTests(); + document.body.replaceChildren(); + vi.restoreAllMocks(); + }); + function button(text: string): HTMLButtonElement { + const found = Array.from(document.querySelectorAll("button")).find(b => b.textContent?.trim() === text); + if (!found) throw new Error(`Missing button ${text}`); + return found; + } + function field(selector: string): HTMLInputElement { + const found = document.querySelector(selector); + if (!found) throw new Error(`Missing input ${selector}`); + return found; + } + async function start(agents: unknown[]) { + fake.resolve("list_all_agents", agents); + rendered = renderWithFakeTransport(() => , fake); + input(field('[placeholder="dream-team"]'), "team"); + click(button("Next")); + await waitFor(() => expect(document.querySelector(".wizard-loading")).toBeNull()); + expect(fake.lastCall("list_all_agents")?.args.destinationProjectPath).toBe(destination); + } + function selectAll(coordinatorIndex = 0) { + document.querySelectorAll('.wizard-agent-row input[type="checkbox"]').forEach(click); + click(document.querySelectorAll('input[type="radio"]')[coordinatorIndex]); + click(button("Next")); + } + function addRepo() { + input(field('[placeholder="https://github.com/org/repo.git"]'), "https://example.test/repo.git"); + click(button("Add Repo")); + } + + it("keeps backend identity, labels, coordinator, repositories and alerts through creation", async () => { + const order: string[] = []; + fake.onInvoke("create_team", () => { order.push("create"); }); + fake.onInvoke("discover_project", () => { order.push("reload"); return discovery(); }); + close.mockImplementation(() => order.push("close")); + await start([local, foreign, other]); + const names = Array.from(document.querySelectorAll(".wizard-agent-name")); + expect(names.map(n => n.textContent)).toEqual(["Copied role", "Visible role@Shared", "Other role@Shared"]); + expect(names[0].querySelector(".wizard-agent-project")).toBeNull(); + expect(names[1].querySelector(".wizard-agent-project")?.textContent).toBe("@Shared"); + selectAll(1); + addRepo(); + click(document.querySelectorAll('.wizard-repo-agents input[type="checkbox"]')[3]); + expect(Array.from(document.querySelectorAll(".wizard-repo-agents .wizard-agent-project")) + .map(n => n.textContent)).toEqual(["@Shared", "@Shared"]); + click(button("Add threshold")); + input(field(".team-context-alert-input"), "75"); + click(button("Create")); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(fake.lastCall("create_team")?.args).toEqual({ + projectPath: destination, name: "team", agents: ["_agent_local", "_agent_remote", "_agent_other"], + coordinator: "_agent_remote", + repos: [{ url: "https://example.test/repo.git", agents: ["_agent_local", "_agent_remote"] }], + contextAlertPercentages: [75], + agentSources: [ + { agentRef: "_agent_local", sourceProjectPath: destination }, + { agentRef: "_agent_remote", sourceProjectPath: "D:/one" }, + { agentRef: "_agent_other", sourceProjectPath: "E:/two" }, + ], + }); + expect(order).toEqual(["create", "reload", "close"]); + }); + + it("rejects portable homonyms without losing selection and permits correction", async () => { + await start([foreign, { ...other, path: "E:/two/.ac/_agent_remote" }]); + selectAll(); + click(button("Create")); + expect(document.querySelector('[aria-label="Team creation error"]')?.textContent).toContain("same portable reference"); + expect(fake.callsFor("create_team")).toHaveLength(0); + click(button("Back")); + const boxes = document.querySelectorAll('.wizard-agent-row input[type="checkbox"]'); + expect(Array.from(boxes).map(b => b.checked)).toEqual([true, true]); + expect(field('input[type="radio"]').checked).toBe(true); + click(boxes[1]); + click(button("Next")); + click(button("Create")); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(fake.lastCall("create_team")?.args.agentSources).toEqual([ + { agentRef: "_agent_remote", sourceProjectPath: "D:/one" }, + ]); + }); + + it("uses backend locality for an alias and a rediscovered copy, including local-only creation", async () => { + await start([{ ...local, projectName: "Alias name", projectPath: "C:/canonical-destination" }]); + expect(document.querySelector(".wizard-agent-name")?.textContent).toBe("Copied role"); + expect(document.querySelector(".wizard-agent-project")).toBeNull(); + selectAll(); + click(button("Create")); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(fake.lastCall("create_team")?.args.agentSources).toEqual([ + { agentRef: "_agent_local", sourceProjectPath: "C:/canonical-destination" }, + ]); + }); + + it("retains state and shows create failure without reloading or closing", async () => { + fake.reject("create_team", "request rejected"); + await start([foreign]); + selectAll(); + addRepo(); + click(button("Create")); + await waitFor(() => expect(document.querySelector('[aria-label="Team creation error"]')?.textContent) + .toContain("request rejected")); + expect(close).not.toHaveBeenCalled(); + expect(fake.callsFor("discover_project")).toHaveLength(0); + expect(button("Create").disabled).toBe(false); + expect(document.querySelector(".wizard-repo-agents")?.textContent).toContain("Visible role@Shared"); + click(button("Back")); + expect(field('input[type="checkbox"]').checked).toBe(true); + expect(field('input[type="radio"]').checked).toBe(true); + }); + + it("waits for real discovery and preserves seeded state on best-effort failure", async () => { + const seed = discovery({ + agents: [{ name: "Existing", path: destination + "/.ac/_agent_existing", roleExists: true }], + teams: [{ name: "existing-team", agents: ["_agent_existing"], coordinator: "_agent_existing" }], + }); + fake.resolve("open_project", { path: destination, registered: true, created: false }); + fake.resolve("discover_project", seed); + await start([foreign]); + await projectStore.loadProject(destination); + expect(projectStore.projects).toHaveLength(1); + expect(projectStore.projects[0].agents).toEqual(seed.agents); + expect(projectStore.projects[0].teams).toEqual(seed.teams); + const before = structuredClone(projectStore.projects); + fake.clearCalls(); // Initial discovery belongs to the seed, not this creation. + + let rejectDiscovery!: (reason: unknown) => void; + const pending = new Promise((_resolve, reject) => { rejectDiscovery = reject; }); + void pending.catch(() => undefined); // Also safe if an earlier assertion prevents consumption. + fake.onInvoke("discover_project", () => pending); + try { + selectAll(); + click(button("Create")); + await waitFor(() => expect(fake.callsFor("discover_project")).toHaveLength(1)); + expect(fake.callsFor("create_team")).toHaveLength(1); + expect(fake.lastCall("discover_project")?.args).toEqual({ path: destination }); + expect(close).not.toHaveBeenCalled(); + expect(document.querySelector(".entity-wizard-modal")?.getAttribute("aria-busy")).toBe("true"); + expect(button("Creating...").disabled).toBe(true); + expect(projectStore.projects).toEqual(before); + + // Existing best-effort reload may leave a stale view. Fake create proves no disk publication. + rejectDiscovery(new Error("discovery rejected")); + await waitFor(() => expect(close).toHaveBeenCalledOnce()); + expect(document.querySelector('[aria-label="Team creation error"]')).toBeNull(); + expect(projectStore.projects).toEqual(before); + expect(fake.callsFor("create_team")).toHaveLength(1); + expect(fake.callsFor("discover_project")).toHaveLength(1); + } finally { + rejectDiscovery(new Error("settle discovery before cleanup")); + // clear() is not cancellation: drain the real consumer before transport/store teardown. + await waitFor(() => expect(document.querySelector(".entity-wizard-modal")?.getAttribute("aria-busy")) + .toBe("false")); + } + }); + + it.each([ + { ...foreign, projectPath: undefined }, + { ...foreign, isLocal: null }, + ])("shows invalid backend identity instead of inferring it", async (agent) => { + await start([agent]); + expect(document.querySelector('[aria-label="Agent selection error"]')?.textContent).toContain("identity is missing"); + expect(document.querySelectorAll(".wizard-agent-row")).toHaveLength(0); + expect(button("Next").disabled).toBe(true); + }); +}); diff --git a/src/sidebar/components/NewTeamModal.tsx b/src/sidebar/components/NewTeamModal.tsx index 465ec5720..30347a72f 100644 --- a/src/sidebar/components/NewTeamModal.tsx +++ b/src/sidebar/components/NewTeamModal.tsx @@ -1,5 +1,6 @@ import { createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js"; import type { Component } from "solid-js"; +import type { TeamAgentSource } from "../../shared/types"; import { EntityAPI } from "../../shared/ipc"; import { projectStore } from "../stores/project"; import { TeamContextAlertsEditor } from "./TeamContextAlertsEditor"; @@ -10,6 +11,8 @@ interface AgentEntry { name: string; path: string; projectName: string; + projectPath: string; + isLocal: boolean; } interface RepoEntry { @@ -87,6 +90,10 @@ const NewTeamModal: Component<{ return last.startsWith("_agent_") ? last : `_agent_${last}`; }; + const agentLabel = (agent: AgentEntry) => ( + <>{agent.name}@{agent.projectName} + ); + const canNext1 = createMemo(() => { const name = teamName().trim(); return name.length > 0 && !name.includes("/") && !name.includes("\\") && !name.includes(" "); @@ -102,16 +109,18 @@ const NewTeamModal: Component<{ setLoadingAgents(true); try { const paths = projectStore.projects.map((project) => project.path); - const result = await EntityAPI.listAllAgents(paths); + const result = await EntityAPI.listAllAgents(paths, props.projectPath); setAllAgents( - result.map((agent) => ({ - name: agent.name, - path: agent.path, - projectName: agent.projectName, - })), + result.map((agent) => { + if (!agent.projectPath?.trim() || typeof agent.isLocal !== "boolean") { + throw new Error("Agent project identity is missing. Reload and try again."); + } + return { ...agent, projectPath: agent.projectPath, isLocal: agent.isLocal }; + }), ); } catch (error: unknown) { console.error("list_all_agents failed:", error); + setCreateError(normalizeCaughtError(error, "Failed to load agents")); } finally { setLoadingAgents(false); } @@ -195,6 +204,15 @@ const NewTeamModal: Component<{ return; } + const agentSources: TeamAgentSource[] = selectedAgentList().map((agent) => ({ + agentRef: portableAgentRef(agent.path), + sourceProjectPath: agent.projectPath, + })); + if (new Set(agentSources.map((source) => source.agentRef)).size !== agentSources.length) { + setCreateError("Selected agents have the same portable reference. Select only one agent per reference."); + return; + } + const request = { projectPath: props.projectPath, name: teamName().trim(), @@ -205,6 +223,7 @@ const NewTeamModal: Component<{ agents: Array.from(repo.agents).map(portableAgentRef), })), contextAlertPercentages: [...validation.canonicalPercentages], + agentSources, }; setCreating(true); @@ -217,6 +236,7 @@ const NewTeamModal: Component<{ request.coordinator, request.repos, request.contextAlertPercentages, + request.agentSources, ); await projectStore.reloadProject(request.projectPath); props.onClose(); @@ -281,6 +301,9 @@ const NewTeamModal: Component<{
+ + +
Loading agents...
@@ -316,7 +339,7 @@ const NewTeamModal: Component<{ checked={isSelected()} onChange={() => toggleAgent(agent.path)} /> - {agent.name} + {agentLabel(agent)} )} diff --git a/src/sidebar/styles/sidebar.css b/src/sidebar/styles/sidebar.css index 24980b18e..560a462b7 100644 --- a/src/sidebar/styles/sidebar.css +++ b/src/sidebar/styles/sidebar.css @@ -9466,6 +9466,10 @@ html.light-theme[data-sidebar-style="neon-circuit"] .coord-quick-access-group { font-size: var(--font-size-sm); } +.wizard-agent-project { + color: var(--sidebar-fg-dim); +} + .wizard-coord-label { display: flex; align-items: center;