feat(orchestrator): create_skill action + wire per-project watchers (§14.3)

- domain: OrchestratorCommand::CreateSkill with optional scope field
  (defaults to Project, case-insensitive, UnknownScope on bad value)
- application: dispatch CreateSkill through the CreateSkill use case
- app-tauri: build OrchestratorService and start/stop per-project request
  watchers on open/create/close_project (ensure/stop_orchestrator_watch)
- tests: domain validation, service dispatch, infra watcher e2e,
  app-tauri wiring lifecycle (idempotent, isolated, stop)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-07 13:06:41 +02:00
parent b9fd2fb925
commit d11eaaa8c0
13 changed files with 619 additions and 52 deletions

View File

@ -19,7 +19,12 @@ import {
fireEvent,
} from "@testing-library/react";
import { MockAgentGateway, MockProfileGateway, MockTemplateGateway } from "@/adapters/mock";
import {
MockAgentGateway,
MockProfileGateway,
MockSystemGateway,
MockTemplateGateway,
} from "@/adapters/mock";
import type { Gateways } from "@/ports";
import { DIProvider } from "@/app/di";
import { AgentsPanel } from "./AgentsPanel";
@ -384,3 +389,45 @@ describe("MockAgentGateway (unit)", () => {
expect(listB).toHaveLength(0);
});
});
describe("AgentsPanel live refresh on domain events", () => {
it("refreshes the list when an `agentLaunched` event fires (out-of-band creation)", async () => {
const agent = new MockAgentGateway();
const profile = new MockProfileGateway();
const system = new MockSystemGateway();
const template = new MockTemplateGateway(agent);
const gateways = { agent, profile, system, template } as unknown as Gateways;
render(
<DIProvider gateways={gateways}>
<AgentsPanel projectId={PROJECT_ID} projectRoot="/home/me/proj" />
</DIProvider>,
);
// Initially empty.
await waitFor(() => expect(screen.getByText("No agents yet.")).toBeTruthy());
// Simulate the orchestrator creating an agent directly in the store
// (bypassing the UI's createAgent path), then emitting `agentLaunched`.
const created = await agent.createAgent(PROJECT_ID, {
name: "Orchestrated",
profileId: "p1",
});
system.emit({
type: "agentLaunched",
agentId: created.id,
sessionId: "sess-1",
});
// The panel must reflect the new agent without any manual reload.
expect(await screen.findByText("Orchestrated")).toBeTruthy();
});
it("does not crash when the system gateway is absent", async () => {
// The existing renderPanel helper injects no `system` gateway; the
// subscription effect must no-op rather than throw.
renderPanel();
await waitForIdle();
expect(screen.getByText("No agents yet.")).toBeTruthy();
});
});

View File

@ -64,7 +64,7 @@ function describe(e: unknown): string {
}
export function useAgents(projectId: string): AgentsViewModel {
const { agent, profile } = useGateways();
const { agent, profile, system } = useGateways();
const [agents, setAgents] = useState<Agent[]>([]);
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
@ -95,6 +95,32 @@ export function useAgents(projectId: string): AgentsViewModel {
void refresh();
}, [refresh]);
// Live refresh: the agents list is otherwise only reloaded on mount or after a
// UI-driven CRUD. An agent created/launched/stopped *out of band* — most
// notably by the orchestrator (`spawn_agent`, ARCHITECTURE §14.3) when an AI
// delegates agent creation to IdeA — emits `agentLaunched` / `agentExited`.
// Subscribing here makes the tab reflect those changes without a manual reload.
// `system` may be absent in unit tests that inject a partial gateway set; guard.
useEffect(() => {
if (!system) return;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
void system
.onDomainEvent((event) => {
if (event.type === "agentLaunched" || event.type === "agentExited") {
void refresh();
}
})
.then((un) => {
if (cancelled) un();
else unsubscribe = un;
});
return () => {
cancelled = true;
unsubscribe?.();
};
}, [system, refresh]);
const createAgent = useCallback(
async (name: string, profileId: string, initialContent?: string) => {
setBusy(true);