From d2df8476ec99c81b96740d8edc95b58ebc104885 Mon Sep 17 00:00:00 2001 From: George Pickett Date: Tue, 17 Feb 2026 16:40:27 -0800 Subject: [PATCH] Review execplan revisions --- ARCHITECTURE.md | 18 +- docs/permissions-sandboxing.md | 105 +- src/app/page.tsx | 244 +---- .../agents/components/AgentCreateModal.tsx | 908 ++---------------- src/features/agents/creation/compiler.ts | 547 ----------- ...ndingGuidedSetupSessionStorageLifecycle.ts | 37 - .../agents/creation/pendingSetupRetry.ts | 38 - .../agents/creation/pendingSetupStore.ts | 160 --- src/features/agents/creation/recovery.ts | 61 -- src/features/agents/creation/types.ts | 92 -- .../agentMutationLifecycleController.ts | 75 -- .../createAgentMutationLifecycleOperation.ts | 103 +- .../agents/operations/createAgentOperation.ts | 91 -- .../agents/operations/guidedCreateWorkflow.ts | 111 --- .../pendingGuidedSetupAutoRetryOperation.ts | 33 - .../pendingGuidedSetupRetryOperation.ts | 61 -- .../pendingSetupLifecycleWorkflow.ts | 84 -- tests/unit/agentCreateModal.test.ts | 188 +--- tests/unit/agentCreationCompiler.test.ts | 224 ----- ...ionLifecycleController.integration.test.ts | 124 +-- .../agentMutationLifecycleController.test.ts | 42 - ...ateAgentMutationLifecycleOperation.test.ts | 148 +-- tests/unit/createAgentOperation.test.ts | 269 ------ .../guidedCreateWorkflow.integration.test.ts | 178 ---- tests/unit/guidedCreateWorkflow.test.ts | 143 --- tests/unit/guidedSetupRecovery.test.ts | 213 ---- .../historySyncOperation.integration.test.ts | 3 +- ...ycleControllerWorkflow.integration.test.ts | 76 -- ...ndingGuidedSetupAutoRetryOperation.test.ts | 53 - tests/unit/pendingGuidedSetupRetry.test.ts | 75 -- .../pendingGuidedSetupRetryOperation.test.ts | 96 -- ...GuidedSetupSessionStorageLifecycle.test.ts | 68 -- tests/unit/pendingGuidedSetupStore.test.ts | 252 ----- .../pendingSetupLifecycleWorkflow.test.ts | 125 --- 34 files changed, 217 insertions(+), 4828 deletions(-) delete mode 100644 src/features/agents/creation/compiler.ts delete mode 100644 src/features/agents/creation/pendingGuidedSetupSessionStorageLifecycle.ts delete mode 100644 src/features/agents/creation/pendingSetupRetry.ts delete mode 100644 src/features/agents/creation/pendingSetupStore.ts delete mode 100644 src/features/agents/creation/recovery.ts delete mode 100644 src/features/agents/operations/createAgentOperation.ts delete mode 100644 src/features/agents/operations/guidedCreateWorkflow.ts delete mode 100644 src/features/agents/operations/pendingGuidedSetupAutoRetryOperation.ts delete mode 100644 src/features/agents/operations/pendingGuidedSetupRetryOperation.ts delete mode 100644 src/features/agents/operations/pendingSetupLifecycleWorkflow.ts delete mode 100644 tests/unit/agentCreationCompiler.test.ts delete mode 100644 tests/unit/createAgentOperation.test.ts delete mode 100644 tests/unit/guidedCreateWorkflow.integration.test.ts delete mode 100644 tests/unit/guidedCreateWorkflow.test.ts delete mode 100644 tests/unit/guidedSetupRecovery.test.ts delete mode 100644 tests/unit/pendingGuidedSetupAutoRetryOperation.test.ts delete mode 100644 tests/unit/pendingGuidedSetupRetry.test.ts delete mode 100644 tests/unit/pendingGuidedSetupRetryOperation.test.ts delete mode 100644 tests/unit/pendingGuidedSetupSessionStorageLifecycle.test.ts delete mode 100644 tests/unit/pendingGuidedSetupStore.test.ts delete mode 100644 tests/unit/pendingSetupLifecycleWorkflow.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3ab25fd..ea305bd 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -27,7 +27,7 @@ Non-goals: This keeps feature cohesion high while preserving a clear client/server boundary. ## Main modules / bounded contexts -- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same **New Agent** entry point, which now opens `AgentCreateModal` as a compact preset flow: preset bundle (with capability chips + risk label) -> control level override -> optional customization -> review. Creation compiles this intent into per-agent setup (files + per-agent overrides + per-agent exec approvals) and applies it with restart-safe orchestration in `src/app/page.tsx`, workflow policy in `src/features/agents/operations/guidedCreateWorkflow.ts`, and setup adapters in `src/features/agents/operations/createAgentOperation.ts`. Pending guided setups are persisted in tab-scoped session storage scoped by normalized gateway URL (helpers in `src/features/agents/creation/pendingGuidedSetupSessionStorageLifecycle.ts`) and surfaced in the focused chat area with `Retry setup` / `Discard pending setup`; reconnect flows perform a one-shot auto-retry when the target agent is present. Retry orchestration is centralized so reconnect auto-retry, manual retry, and restart-complete apply paths share one in-flight guard and avoid duplicate concurrent applies for the same agent. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (`model`, `thinking`) live in the chat header (`AgentChatPanel`), active runs can be stopped from the chat composer via `chat.abort`, and pending exec approvals render in-chat action cards (`Allow once`, `Always allow`, `Deny`) while fleet rows show `Needs approval`. Settings sidebar actions remain focused on rename, display toggles, new session, cron list/run/delete/create, and delete (`AgentSettingsPanel`). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (`presence`/`heartbeat` summary refresh and `chat`/`agent` runtime streams) is centralized in bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`), while runtime flow decisions are emitted from pure policy helpers (`src/features/agents/state/runtimeEventPolicy.ts`) and executed by `src/features/agents/state/gatewayRuntimeEventHandler.ts`; both are consumed from one gateway subscription path in `src/app/page.tsx`, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under `src/features/agents/operations/` (fleet hydration snapshots in `agentFleetHydration.ts`, pure fleet hydration derivation in `agentFleetHydrationDerivation.ts`, chat send in `chatSendOperation.ts`, cron create in `cronCreateOperation.ts`, guided create/retry policy in `guidedCreateWorkflow.ts`, setup adapters in `createAgentOperation.ts`, config mutation policy in `configMutationWorkflow.ts`, mutation lifecycle controller policy in `agentMutationLifecycleController.ts`, pending setup lifecycle policy in `pendingSetupLifecycleWorkflow.ts`, pending guided setup retry operation in `pendingGuidedSetupRetryOperation.ts`, pending guided setup auto-retry operation in `pendingGuidedSetupAutoRetryOperation.ts`, latest-update policy in `latestUpdateWorkflow.ts`, fleet summary/reconcile policy in `fleetLifecycleWorkflow.ts`, reconcile operation adapter in `agentReconcileOperation.ts`, history request/disposition policy in `historyLifecycleWorkflow.ts`, history sync operation adapter in `historySyncOperation.ts`, approval lifecycle policy in `src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, manual exec approval resolve operation in `src/features/agents/approvals/execApprovalResolveOperation.ts`, and execution primitives in `useConfigMutationQueue.ts` and `useGatewayRestartBlock.ts`). Rename/delete post-run UI side effects are emitted as typed mutation commands from `agentMutationLifecycleController.ts` and executed in `src/app/page.tsx`. Session setting mutations (model/thinking) are centralized in `src/features/agents/state/sessionSettingsMutations.ts` so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from `chat.history` sync via the history workflow boundary (`historyLifecycleWorkflow.ts`) and operation adapter (`historySyncOperation.ts`); replayed terminal chat events and late deltas from recently closed runs are ignored in `gatewayRuntimeEventHandler`, which requests recovery history only through the `requestHistoryRefresh` boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first `GatewayConnectScreen` with a local command copy affordance and a collapsible remote form. +- **Focused agent UI** (`src/features/agents`): focused agent panel, fleet sidebar, inspect panel, and local in-memory state + actions. The fleet sidebar keeps the same **New Agent** entry point, now implemented as a one-step `AgentCreateModal` flow (name + avatar + launch). Creation is create-only: `src/app/page.tsx` calls `runCreateAgentMutationLifecycle` and `createGatewayAgent`, then reloads fleet state and focuses chat for the new agent; no guided setup compilation, deferred setup persistence, or pending retry UI remains in runtime flows. Agents render a status-first summary and latest-update preview driven by gateway events. Per-agent runtime controls (`model`, `thinking`) live in the chat header (`AgentChatPanel`), active runs can be stopped from the chat composer via `chat.abort`, and pending exec approvals render in-chat action cards (`Allow once`, `Always allow`, `Deny`) while fleet rows show `Needs approval`. Settings sidebar actions remain focused on rename, display toggles, execution role updates (`updateExecutionRoleViaStudio`), new session, cron list/run/delete/create, and delete (`AgentSettingsPanel`). Cron creation continues to use a guided modal scoped to the selected settings agent. Gateway event classification (`presence`/`heartbeat` summary refresh and `chat`/`agent` runtime streams) is centralized in bridge helpers (`src/features/agents/state/runtimeEventBridge.ts`), while runtime flow decisions are emitted from pure policy helpers (`src/features/agents/state/runtimeEventPolicy.ts`) and executed by `src/features/agents/state/gatewayRuntimeEventHandler.ts`; both are consumed from one gateway subscription path in `src/app/page.tsx`, where exec approval events are handled in parallel. Higher-level orchestration is factored into operations under `src/features/agents/operations/` (fleet hydration snapshots in `agentFleetHydration.ts`, pure fleet hydration derivation in `agentFleetHydrationDerivation.ts`, chat send in `chatSendOperation.ts`, cron create in `cronCreateOperation.ts`, create mutation lifecycle in `createAgentMutationLifecycleOperation.ts`, config mutation policy in `configMutationWorkflow.ts`, mutation lifecycle controller policy in `agentMutationLifecycleController.ts`, latest-update policy in `latestUpdateWorkflow.ts`, fleet summary/reconcile policy in `fleetLifecycleWorkflow.ts`, reconcile operation adapter in `agentReconcileOperation.ts`, history request/disposition policy in `historyLifecycleWorkflow.ts`, history sync operation adapter in `historySyncOperation.ts`, approval lifecycle policy in `src/features/agents/approvals/execApprovalLifecycleWorkflow.ts`, manual exec approval resolve operation in `src/features/agents/approvals/execApprovalResolveOperation.ts`, and execution primitives in `useConfigMutationQueue.ts` and `useGatewayRestartBlock.ts`). Rename/delete post-run UI side effects are emitted as typed mutation commands from `agentMutationLifecycleController.ts` and executed in `src/app/page.tsx`. Session setting mutations (model/thinking) are centralized in `src/features/agents/state/sessionSettingsMutations.ts` so optimistic state updates and sync/error behavior stay aligned. Transcript ownership is split intentionally: optimistic send appends local user transcript entries while canonical timestamps and final ordering come from `chat.history` sync via the history workflow boundary (`historyLifecycleWorkflow.ts`) and operation adapter (`historySyncOperation.ts`); replayed terminal chat events and late deltas from recently closed runs are ignored in `gatewayRuntimeEventHandler`, which requests recovery history only through the `requestHistoryRefresh` boundary command. Studio fetches a capped amount of chat history by default (currently 200 messages) and exposes a “Load more” affordance when the transcript may be truncated. Disconnected startup now uses a status-first `GatewayConnectScreen` with a local command copy affordance and a collapsible remote form. - **Studio settings** (`src/lib/studio`, `src/app/api/studio`): local settings store for gateway URL/token and focused preferences (`src/lib/studio/settings.ts`, `src/lib/studio/settings-store.ts`, `src/app/api/studio/route.ts`). `src/lib/studio/coordinator.ts` now owns both the `/api/studio` transport helpers and shared client-side load/patch scheduling for gateway and focused settings. - **Gateway** (`src/lib/gateway`): WebSocket client for agent runtime (frames, connect, request/response). Session settings sync transport (`sessions.patch`) is centralized in `src/lib/gateway/GatewayClient.ts`. Connect failures surfaced through the Studio WS proxy are preserved as `GatewayResponseError` codes (parsed from `connect failed: ...`) so `useGatewayConnection` can gate auto-retry via `resolveGatewayAutoRetryDelayMs`. The OpenClaw control UI client is vendored in `src/lib/gateway/openclaw/GatewayBrowserClient.ts` with a sync script at `scripts/sync-openclaw-gateway-client.ts`. - **Studio gateway proxy server** (`server/index.js`, `server/gateway-proxy.js`, `server/studio-settings.js`): custom Next server that terminates browser WS at `/api/gateway/ws`, loads upstream gateway URL/token server-side, injects auth token when needed, and forwards frames to the upstream gateway. @@ -78,17 +78,15 @@ Flow: ### 3) Agent create + per-agent setup - **Agent files**: `AGENTS.md`, `SOUL.md`, `IDENTITY.md`, `USER.md`, `TOOLS.md`, `HEARTBEAT.md`, `MEMORY.md`. -- **Create + heartbeat + rename + guided per-agent overrides**: stored in gateway config and updated via `config.get` + `config.patch`. -- **Exec approvals policy**: stored in exec approvals file and updated via `exec.approvals.get` + `exec.approvals.set`. +- **Create + heartbeat + rename + per-agent overrides**: stored in gateway config and updated via `config.get` + `config.patch`. +- **Exec approvals policy**: managed after creation through settings/runtime approval flows. Flow: -1. `AgentCreateModal` captures preset-bundle selection, optional control-level override, and customization input (`src/features/agents/components/AgentCreateModal.tsx`). -2. Creation input is compiled into concrete artifacts (`src/features/agents/creation/compiler.ts`): agent files, per-agent overrides, and per-agent exec approvals policy. Tool additions use additive `tools.alsoAllow` semantics so selected profile defaults remain available. -3. Local gateways apply setup immediately after `agents.create`; remote gateways persist a pending setup payload and apply it after restart in `useGatewayRestartBlock`. -4. If setup apply fails after agent creation, Studio keeps the created agent, preserves pending setup state in gateway-scoped session storage, shows retry/discard controls in chat, and retries once automatically after reconnect when the agent exists. -5. Retry apply paths (manual retry, reconnect auto-retry, restart-complete apply) share one in-flight guard to prevent duplicate concurrent setup writes per agent. -6. Agent file writes call `agents.files.set` through `writeGatewayAgentFiles` (`src/lib/gateway/agentFiles.ts`), and per-agent approvals policy writes call `upsertGatewayAgentExecApprovals` (`src/lib/gateway/execApprovals.ts`). -7. UI reflects persisted state returned by the gateway. +1. `AgentCreateModal` captures only create payload (`name`, optional `avatarSeed`) in a one-step launch flow (`src/features/agents/components/AgentCreateModal.tsx`). +2. `runCreateAgentMutationLifecycle` enqueues one create mutation, applies queue guardrails, and drives create block status (`src/features/agents/operations/createAgentMutationLifecycleOperation.ts`). +3. `createGatewayAgent` calls `config.get` to derive workspace, then calls `agents.create` with `{ name, workspace }` (`src/lib/gateway/agentConfig.ts`). +4. Studio reloads fleet state and focuses the created agent chat in `src/app/page.tsx`. +5. Any authority/runtime adjustments happen after creation through settings operations such as `updateExecutionRoleViaStudio` (`src/features/agents/operations/executionRoleUpdateOperation.ts`). ### 4) Cron summaries + settings controls - **Cron**: the UI calls gateway cron methods directly (`cron.list`, `cron.add`, `cron.run`, `cron.remove`) for latest-update previews and agent settings controls. diff --git a/docs/permissions-sandboxing.md b/docs/permissions-sandboxing.md index 082af76..f92f47e 100644 --- a/docs/permissions-sandboxing.md +++ b/docs/permissions-sandboxing.md @@ -7,7 +7,7 @@ This document exists to onboard coding agents quickly when debugging: - How “create agent” choices in **OpenClaw Studio** flow into the **OpenClaw Gateway** (often running on an EC2 host) where enforcement actually happens Scope: -- Studio guided agent creation (the “authority level” / permissions choices) and the exact gateway calls it makes. +- Studio one-step agent creation and post-create authority updates, including exact gateway calls. - The upstream OpenClaw implementation that persists and enforces those settings at runtime. Non-scope: @@ -41,83 +41,50 @@ The Gateway (OpenClaw) is the enforcement point: ## Studio: Where “Permissions” Are Chosen -Guided creation UI: -- `src/features/agents/components/AgentCreateModal.tsx` +Agent creation is now create-only: +- `src/features/agents/components/AgentCreateModal.tsx` captures `name` and optional avatar shuffle seed. +- `src/features/agents/operations/createAgentMutationLifecycleOperation.ts` applies queue/guard behavior and calls create. +- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) performs `config.get` + `agents.create`. -Guided draft compilation (UI intent -> concrete gateway setup): -- `src/features/agents/creation/compiler.ts` (`compileGuidedAgentCreation`) +Authority/permission changes happen after creation from settings: +- `src/features/agents/operations/executionRoleUpdateOperation.ts` (`updateExecutionRoleViaStudio`) + - updates per-agent exec approvals (`exec.approvals.get` + `exec.approvals.set`) + - updates runtime tool overrides (`config.get` + `config.patch` via `updateGatewayAgentOverrides`) + - updates session exec behavior (`sessions.patch` via `syncGatewaySessionSettings`) -Key outputs of compilation: -- `files`: content for `IDENTITY.md`, `SOUL.md`, etc (written via gateway `agents.files.set`) -- `agentOverrides`: - - `sandbox.mode` - - `sandbox.workspaceAccess` - - `tools.profile` - - `tools.alsoAllow` - - `tools.deny` -- `execApprovals` (when exec/runtime tools are enabled): - - `security`: `deny | allowlist | full` - - `ask`: `off | on-miss | always` - - `allowlist`: patterns +### Runtime Tool Groups Used By Post-Create Role Updates -### Tool Profiles And Tool Groups (What Studio Is Actually Writing) - -Studio’s guided presets use the same tool profiles and tool groups that upstream OpenClaw expands: -- Upstream groups + profiles: `openclaw/src/agents/tool-policy.ts` - -Common groups referenced by Studio: -- `group:fs`: `read`, `write`, `edit`, `apply_patch` -- `group:runtime`: `exec`, `process` -- `group:web`: `web_search`, `web_fetch` -- `group:sessions`: `sessions_*` tools -- `group:memory`: memory tools +Studio role updates still rely on OpenClaw tool-group expansion (`openclaw/src/agents/tool-policy.ts`), especially: +- `group:runtime` -> runtime execution tools (`exec`, `process`) What this means in practice: -- If Studio denies `group:runtime`, upstream expands that into denying `exec` and `process`, so command execution becomes unavailable regardless of exec approvals policy. -- If Studio denies `write/edit/apply_patch` directly (as it does in “propose-only”), upstream will enforce that at tool selection time even if the sandbox has a writable workspace directory. - -### Studio Gotcha: `sandbox.mode` Is Currently Hardcoded - -In `compileGuidedAgentCreation`, Studio currently sets: -- `const normalizedSandboxMode = "off";` - -That means guided creation always writes `sandbox.mode = "off"` for newly created agents. +- Conservative role removes runtime group access and sets exec approvals to deny. +- Collaborative/autonomous roles include runtime group access and set exec approvals to allowlist/full respectively. ## Studio -> Gateway: “Create Agent” End-to-End Primary entry points: -- `src/features/agents/operations/createAgentOperation.ts` (`createAgentWithOptionalSetup`, `applyGuidedAgentSetup`) -- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`, `updateGatewayAgentOverrides`) -- `src/lib/gateway/agentFiles.ts` (writes bootstrap files) -- `src/lib/gateway/execApprovals.ts` (writes per-agent exec approvals policy) +- `src/features/agents/operations/createAgentMutationLifecycleOperation.ts` +- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) -Sequence (local gateway): +Sequence: ```mermaid sequenceDiagram participant UI as Studio UI - participant C as Studio compiler + participant L as Create lifecycle participant GC as Studio GatewayClient participant G as OpenClaw Gateway - UI->>C: compileGuidedAgentCreation(draft) - C-->>UI: { files, agentOverrides, execApprovals } - - UI->>GC: createGatewayAgent(name) + UI->>L: submit({ name, avatarSeed? }) + L->>GC: createGatewayAgent(name) GC->>G: config.get G-->>GC: { path: ".../openclaw.json", ... } GC->>G: agents.create({ name, workspace: "/workspace-" }) G-->>GC: { agentId, workspace } - - UI->>GC: applyGuidedAgentSetup(agentId) - GC->>G: agents.files.set (write files) - GC->>G: exec.approvals.set (per-agent policy) - GC->>G: config.set (agentOverrides) + L-->>UI: completion(agentId) ``` -Remote gateway nuance: -- `createAgentWithOptionalSetup` returns `awaitingRestart: true` for non-local gateways and defers applying the setup until Studio’s restart-block workflow runs. (The settings still end up persisted on the gateway host; Studio is just coordinating when to write them.) - ### How Studio Chooses the Default Workspace Path Studio computes a default workspace path from the gateway’s config path: @@ -131,17 +98,9 @@ Logic: Important: for a remote gateway (EC2), that `workspace` path refers to the gateway host filesystem, not your laptop. -## Studio: Sandbox Env Allowlist Sync (Why It Exists) +## Studio: Sandbox Env Allowlist Sync (Current Scope) -When guided setup applies, Studio first calls: -- `src/lib/gateway/sandboxEnvAllowlist.ts` (`ensureGatewaySandboxEnvAllowlistFromDotEnv`) - -Behavior: -- Reads dotenv keys from `/api/gateway/dotenv-keys` (best-effort; if the route is missing it returns). -- Patches the gateway config (`config.set`) to ensure `agents.defaults.sandbox.docker.env` includes entries like: - - `FOO: "${FOO}"` - -This is a “plumbing” step so sandbox containers can receive expected environment variables without Studio needing to hardcode them into agent templates. +Create flow does not perform setup writes during initial create anymore. If Studio needs to ensure sandbox env allowlist entries, that behavior should be attached to explicit settings/config operations rather than create-time side effects. ## OpenClaw (Upstream): What `agents.create` Actually Does @@ -264,17 +223,19 @@ Key enforcement: This is why “`workspaceAccess=ro`” means more than “mount it read-only”: - It is also a tool-policy gate that prevents direct file writes/edits through PI tools. -### Studio Gotcha: Guided Validation Does Not Match Upstream Enforcement +### Studio Note: Authority Is No Longer Compiled During Create -In `src/features/agents/creation/compiler.ts`, Studio currently validates: -- “Auto file edits require sandbox workspace access ro or rw.” +Studio create flow no longer compiles authority/sandbox settings during initial create. -But upstream OpenClaw disables PI write/edit/apply_patch when `workspaceAccess === "ro"`. +When authority is changed post-create, Studio uses: +- `src/features/agents/operations/executionRoleUpdateOperation.ts` -So if “auto-edit” is intended to mean “agent can apply edits via tools to the real workspace”, the effective requirement is: -- `sandbox.workspaceAccess = "rw"` +That operation updates: +- exec approvals policy (`exec.approvals.set`) +- per-agent tool overrides (`config.patch` via `updateGatewayAgentOverrides`) +- session exec host/security/ask (`sessions.patch`) -If `workspaceAccess = "none"`, upstream may still allow sandboxed write/edit tools, but those edits apply to the sandbox workspace, not the agent workspace (and the agent workspace is not mounted). +Upstream enforcement is unchanged: `workspaceAccess="ro"` still disables PI `write`/`edit`/`apply_patch` in sandboxed sessions. ## Session-Level Exec Settings (Where `exec` Runs) diff --git a/src/app/page.tsx b/src/app/page.tsx index 03b520f..31a9a78 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -68,22 +68,6 @@ import { createStudioSettingsCoordinator } from "@/lib/studio/coordinator"; import { resolveFocusedPreference } from "@/lib/studio/settings"; import { applySessionSettingMutation } from "@/features/agents/state/sessionSettingsMutations"; import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types"; -import { - applyPendingGuidedSetupForAgent, - removePendingGuidedSetup, - upsertPendingGuidedSetup, -} from "@/features/agents/creation/recovery"; -import { - normalizePendingGuidedSetupGatewayScope, -} from "@/features/agents/creation/pendingSetupStore"; -import { - loadPendingGuidedSetupsForScope, - persistPendingGuidedSetupsForScopeWhenLoaded, -} from "@/features/agents/creation/pendingGuidedSetupSessionStorageLifecycle"; -import { - applyGuidedAgentSetup, - type AgentGuidedSetup, -} from "@/features/agents/operations/createAgentOperation"; import { isGatewayDisconnectLikeError, type EventFrame, @@ -142,12 +126,10 @@ import { buildQueuedMutationBlock, resolveMutationStartGuard, } from "@/features/agents/operations/agentMutationLifecycleController"; -import { runPendingGuidedSetupAutoRetryViaStudio } from "@/features/agents/operations/pendingGuidedSetupAutoRetryOperation"; import { runAgentConfigMutationLifecycle } from "@/features/agents/operations/agentConfigMutationLifecycleOperation"; import { isCreateBlockTimedOut, runCreateAgentMutationLifecycle, - runPendingCreateSetupRetryLifecycle, } from "@/features/agents/operations/createAgentMutationLifecycleOperation"; const DEFAULT_CHAT_HISTORY_LIMIT = 200; @@ -163,9 +145,8 @@ type DeleteAgentBlockState = { startedAt: number; sawDisconnect: boolean; }; -type CreateAgentBlockPhase = "queued" | "creating" | "applying-setup"; +type CreateAgentBlockPhase = "queued" | "creating"; type CreateAgentBlockState = { - agentId: string | null; agentName: string; phase: CreateAgentBlockPhase; startedAt: number; @@ -259,19 +240,9 @@ const AgentStudioPage = () => { const [unscopedPendingExecApprovals, setUnscopedPendingExecApprovals] = useState< PendingExecApproval[] >([]); - const [pendingCreateSetupsByAgentId, setPendingCreateSetupsByAgentId] = useState< - Record - >({}); - const [pendingCreateSetupsLoadedScope, setPendingCreateSetupsLoadedScope] = useState< - string | null - >(null); - const [retryPendingSetupBusyAgentId, setRetryPendingSetupBusyAgentId] = useState( - null - ); const specialUpdateRef = useRef>(new Map()); const seenCronEventIdsRef = useRef>(new Set()); const preferredSelectedAgentIdRef = useRef(null); - const pendingCreateSetupsByAgentIdRef = useRef>({}); const pendingDraftValuesRef = useRef>(new Map()); const pendingDraftTimersRef = useRef>(new Map()); const pendingLivePatchesRef = useRef>>(new Map()); @@ -281,8 +252,6 @@ const AgentStudioPage = () => { null ); const reconcileRunInFlightRef = useRef>(new Set()); - const pendingSetupAutoRetryAttemptedRef = useRef>(new Set()); - const pendingSetupAutoRetryInFlightRef = useRef>(new Set()); const approvalPausedRunIdByAgentRef = useRef>(new Map()); const agents = state.agents; @@ -329,12 +298,6 @@ const AgentStudioPage = () => { return "New Agent"; } }, [state.agents]); - const focusedPendingCreateSetup = useMemo(() => { - if (!focusedAgentId) return null; - return pendingCreateSetupsByAgentId[focusedAgentId] ?? null; - }, [focusedAgentId, pendingCreateSetupsByAgentId]); - const focusedPendingCreateSetupBusy = - focusedAgent !== null && retryPendingSetupBusyAgentId === focusedAgent.agentId; const faviconSeed = useMemo(() => { const firstAgent = agents[0]; const seed = firstAgent?.avatarSeed ?? firstAgent?.agentId ?? ""; @@ -351,10 +314,6 @@ const AgentStudioPage = () => { ); const hasRunningAgents = runningAgentCount > 0; const isLocalGateway = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]); - const pendingGuidedSetupGatewayScope = useMemo( - () => normalizePendingGuidedSetupGatewayScope(gatewayUrl), - [gatewayUrl] - ); const hasRestartBlockInProgress = Boolean( (deleteAgentBlock && deleteAgentBlock.phase !== "queued") || @@ -690,49 +649,10 @@ const AgentStudioPage = () => { }); }, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]); - const applyPendingCreateSetupForAgentId = useCallback( - async (params: { agentId: string; source: "auto" | "manual" }) => { - return await runPendingCreateSetupRetryLifecycle({ - agentId: params.agentId, - source: params.source, - retryBusyAgentId: retryPendingSetupBusyAgentId, - inFlightAgentIds: pendingSetupAutoRetryInFlightRef.current, - pendingSetupsByAgentId: pendingCreateSetupsByAgentIdRef.current, - setRetryBusyAgentId: setRetryPendingSetupBusyAgentId, - applyPendingSetup: async (targetAgentId) => - applyPendingGuidedSetupForAgent({ - client, - agentId: targetAgentId, - pendingSetupsByAgentId: pendingCreateSetupsByAgentIdRef.current, - }), - removePending: (targetAgentId) => { - setPendingCreateSetupsByAgentId((current) => - removePendingGuidedSetup(current, targetAgentId) - ); - }, - isDisconnectLikeError: isGatewayDisconnectLikeError, - resolveAgentName: (agentId) => - stateRef.current.agents.find((agent) => agent.agentId === agentId)?.name ?? - agentId, - onApplied: async () => { - await loadAgents(); - }, - onError: (message) => { - setError(message); - }, - }); - }, - [client, loadAgents, retryPendingSetupBusyAgentId, setError] - ); - useEffect(() => { stateRef.current = state; }, [state]); - useEffect(() => { - pendingCreateSetupsByAgentIdRef.current = pendingCreateSetupsByAgentId; - }, [pendingCreateSetupsByAgentId]); - useEffect(() => { if (status === "connected") return; setAgentsLoadedOnce(false); @@ -850,65 +770,6 @@ const AgentStudioPage = () => { } }, [setLoading, status]); - useEffect(() => { - const loaded = loadPendingGuidedSetupsForScope({ - storage: window.sessionStorage, - gatewayScope: pendingGuidedSetupGatewayScope, - }); - setPendingCreateSetupsByAgentId(loaded.setupsByAgentId); - setPendingCreateSetupsLoadedScope(loaded.loadedScope); - }, [pendingGuidedSetupGatewayScope]); - - useEffect(() => { - pendingSetupAutoRetryAttemptedRef.current.clear(); - pendingSetupAutoRetryInFlightRef.current.clear(); - setRetryPendingSetupBusyAgentId(null); - }, [pendingGuidedSetupGatewayScope]); - - useEffect(() => { - if (status === "connected") return; - pendingSetupAutoRetryAttemptedRef.current.clear(); - pendingSetupAutoRetryInFlightRef.current.clear(); - setRetryPendingSetupBusyAgentId(null); - }, [status]); - - useEffect(() => { - persistPendingGuidedSetupsForScopeWhenLoaded({ - storage: window.sessionStorage, - gatewayScope: pendingGuidedSetupGatewayScope, - loadedScope: pendingCreateSetupsLoadedScope, - setupsByAgentId: pendingCreateSetupsByAgentId, - }); - }, [pendingCreateSetupsByAgentId, pendingCreateSetupsLoadedScope, pendingGuidedSetupGatewayScope]); - - useEffect(() => { - void runPendingGuidedSetupAutoRetryViaStudio({ - status, - agentsLoadedOnce, - loadedScopeMatches: pendingCreateSetupsLoadedScope === pendingGuidedSetupGatewayScope, - hasActiveCreateBlock: Boolean(createAgentBlock && createAgentBlock.phase !== "queued"), - retryBusyAgentId: retryPendingSetupBusyAgentId, - pendingSetupsByAgentId: pendingCreateSetupsByAgentId, - knownAgentIds: new Set(agents.map((agent) => agent.agentId)), - attemptedAgentIds: pendingSetupAutoRetryAttemptedRef.current, - inFlightAgentIds: pendingSetupAutoRetryInFlightRef.current, - applyRetry: (agentId) => - applyPendingCreateSetupForAgentId({ - agentId, - source: "auto", - }), - }); - }, [ - agents, - agentsLoadedOnce, - applyPendingCreateSetupForAgentId, - createAgentBlock, - pendingCreateSetupsByAgentId, - pendingCreateSetupsLoadedScope, - pendingGuidedSetupGatewayScope, - retryPendingSetupBusyAgentId, - status, - ]); useEffect(() => { if (!settingsAgentId) return; @@ -1518,7 +1379,6 @@ const AgentStudioPage = () => { hasRenameBlock: Boolean(renameAgentBlock), hasDeleteBlock: Boolean(deleteAgentBlock), createAgentBusy, - isLocalGateway, }, { enqueueConfigMutation, @@ -1535,23 +1395,6 @@ const AgentStudioPage = () => { setMobilePane("chat"); return { id: created.id }; }, - applySetup: async (agentId, setup) => { - await applyGuidedAgentSetup({ - client, - agentId, - setup, - }); - }, - upsertPending: (agentId, setup) => { - setPendingCreateSetupsByAgentId((current) => - upsertPendingGuidedSetup(current, agentId, setup) - ); - }, - removePending: (agentId) => { - setPendingCreateSetupsByAgentId((current) => - removePendingGuidedSetup(current, agentId) - ); - }, setQueuedBlock: ({ agentName, startedAt }) => { const queuedCreateBlock = buildQueuedMutationBlock({ kind: "create-agent", @@ -1560,7 +1403,6 @@ const AgentStudioPage = () => { startedAt, }); setCreateAgentBlock({ - agentId: null, agentName: queuedCreateBlock.agentName, phase: "queued", startedAt: queuedCreateBlock.startedAt, @@ -1572,26 +1414,12 @@ const AgentStudioPage = () => { return { ...current, phase: "creating" }; }); }, - setApplyingSetupBlock: ({ agentName, agentId }) => { - setCreateAgentBlock((current) => { - if (!current || current.agentName !== agentName) return current; - return { ...current, agentId, phase: "applying-setup" }; - }); - }, - onCompletion: async (completion) => { - if (completion.shouldReloadAgents) { - await loadAgents(); - } + onCompletion: async () => { + await loadAgents(); setCreateAgentBlock(null); - if (completion.shouldCloseCreateModal) { - setCreateAgentModalOpen(false); - } + setCreateAgentModalOpen(false); setMobilePane("chat"); - if (completion.pendingErrorMessage) { - setError(completion.pendingErrorMessage); - } }, - setCreateAgentModalOpen, setCreateAgentModalError, setCreateAgentBusy, clearCreateBlock: () => { @@ -1610,7 +1438,6 @@ const AgentStudioPage = () => { enqueueConfigMutation, flushPendingDraft, focusedAgent, - isLocalGateway, loadAgents, persistAvatarSeed, renameAgentBlock, @@ -1843,30 +1670,6 @@ const AgentStudioPage = () => { [dispatch] ); - const handleRetryPendingCreateSetup = useCallback( - async (agentId: string) => { - const resolvedAgentId = agentId.trim(); - if (!resolvedAgentId) return; - await applyPendingCreateSetupForAgentId({ - agentId: resolvedAgentId, - source: "manual", - }); - }, - [applyPendingCreateSetupForAgentId] - ); - - const handleDiscardPendingCreateSetup = useCallback((agentId: string) => { - const resolvedAgentId = agentId.trim(); - if (!resolvedAgentId) return; - const confirmed = window.confirm( - `Discard pending guided setup for "${resolvedAgentId}"? The agent will remain unchanged.` - ); - if (!confirmed) return; - setPendingCreateSetupsByAgentId((current) => - removePendingGuidedSetup(current, resolvedAgentId) - ); - }, []); - const handleResolveExecApproval = useCallback( async (approvalId: string, decision: ExecApprovalDecision) => { await resolveExecApprovalViaStudio({ @@ -2351,9 +2154,7 @@ const AgentStudioPage = () => { ? "Waiting for active runs to finish" : createAgentBlock.phase === "creating" ? "Submitting config change" - : createAgentBlock.phase === "applying-setup" - ? "Applying guided setup" - : null + : null : null; const renameBlockStatusLine = resolveConfigMutationStatusLine({ block: renameAgentBlock @@ -2581,40 +2382,6 @@ const AgentStudioPage = () => { > {focusedAgent ? (
- {focusedPendingCreateSetup ? ( -
-
- Guided setup pending -
-
- This agent was created, but setup did not finish. Retry setup now or discard - the pending setup and keep the current agent state. -
-
- - -
-
- ) : null}
{
{createAgentModalOpen ? ( { - if (selected) { - if (emphasis === "full") { - return "rounded-md border border-primary/60 bg-surface-2 px-3 py-2 text-left text-xs font-semibold text-foreground shadow-sm transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"; - } - if (emphasis === "guided") { - return "rounded-md border border-primary/50 bg-primary/10 px-3 py-2 text-left text-xs font-semibold text-foreground transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"; - } - return "rounded-md border border-primary/45 bg-surface-2 px-3 py-2 text-left text-xs font-semibold text-foreground transition focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"; - } - return "rounded-md border border-border/80 bg-surface-3 px-3 py-2 text-left text-xs text-muted-foreground transition hover:border-border hover:bg-surface-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"; -}; -type StarterDirectionTile = { - id: string; - bundle: AgentPresetBundle; - title: string; - description: string; - accountabilityPoints: string[]; - launchActions: string[]; - icon: LucideIcon; - railClassName: string; - iconClassName: string; -}; - -const STARTER_DIRECTIONS: StarterDirectionTile[] = [ - { - id: "execution", - bundle: "coordinator", - title: "Execution", - description: "Owns delivery across priorities.", - accountabilityPoints: [ - "Tracking milestones across active priorities", - "Escalating and resolving blockers quickly", - "Maintaining clear ownership and follow-through", - "Publishing progress updates on cadence", - ], - launchActions: [ - "Audit current priorities and active milestones", - "Identify bottlenecks and unresolved blockers", - "Propose and begin executing next actions", - ], - icon: ListChecks, - railClassName: "border-l-slate-500/25", - iconClassName: "text-slate-500/70", - }, - { - id: "product", - bundle: "pr-engineer", - title: "Product", - description: "Owns shipping velocity and product quality.", - accountabilityPoints: [ - "Maintaining consistent shipping velocity", - "Reducing defect volume and regressions", - "Unblocking implementation bottlenecks", - "Reporting product delivery progress", - ], - launchActions: [ - "Review active product work and quality signals", - "Flag execution bottlenecks and defect risk", - "Start the highest-leverage delivery actions", - ], - icon: Layers, - railClassName: "border-l-blue-500/25", - iconClassName: "text-blue-500/65", - }, - { - id: "growth", - bundle: "growth-operator", - title: "Growth", - description: "Owns acquisition and conversion performance.", - accountabilityPoints: [ - "Monitoring traffic and conversion trends", - "Diagnosing funnel drop-offs", - "Running acquisition and conversion experiments", - "Reporting performance and next actions", - ], - launchActions: [ - "Assess current acquisition and funnel performance", - "Identify the highest-impact optimization opportunities", - "Launch targeted growth experiments", - ], - icon: TrendingUp, - railClassName: "border-l-emerald-500/25", - iconClassName: "text-emerald-500/70", - }, - { - id: "revenue", - bundle: "growth-operator", - title: "Revenue", - description: "Owns monetization and pricing performance.", - accountabilityPoints: [ - "Monitoring revenue performance", - "Identifying root causes of decline", - "Running pricing and offer experiments", - "Reporting progress autonomously", - ], - launchActions: [ - "Audit current monetization and pricing signals", - "Surface key revenue constraints and opportunities", - "Start priority pricing and offer experiments", - ], - icon: ChartLine, - railClassName: "border-l-amber-500/25", - iconClassName: "text-amber-500/70", - }, - { - id: "systems", - bundle: "autonomous-engineer", - title: "Systems", - description: "Owns operational leverage and automation.", - accountabilityPoints: [ - "Replacing repetitive manual workflows", - "Keeping automation runs reliable", - "Stabilizing reporting pipelines", - "Driving throughput with fewer handoffs", - ], - launchActions: [ - "Map repetitive workflows and manual bottlenecks", - "Prioritize automations with immediate leverage", - "Begin implementation of high-value system improvements", - ], - icon: Workflow, - railClassName: "border-l-cyan-500/25", - iconClassName: "text-cyan-500/65", - }, - { - id: "strategy", - bundle: "research-analyst", - title: "Strategy", - description: "Owns prioritization and capital allocation.", - accountabilityPoints: [ - "Clarifying priorities and strategic tradeoffs", - "Evaluating options with evidence", - "Recommending allocation decisions", - "Summarizing rationale for leadership", - ], - launchActions: [ - "Review active priorities and strategic constraints", - "Frame key tradeoffs and decision paths", - "Deliver an initial direction with clear rationale", - ], - icon: Compass, - railClassName: "border-l-violet-500/25", - iconClassName: "text-violet-500/60", - }, -]; - -type CommandMode = "off" | "ask-first" | "auto"; -type FileMode = "off" | "on"; -type AutonomyProfileId = "conservative" | "collaborative" | "autonomous"; - -const AUTONOMY_PROFILES: Array<{ - id: AutonomyProfileId; - title: string; - description: string; - details: string[]; -}> = [ - { - id: "conservative", - title: "Conservative", - description: "Acts with review required.", - details: [ - "Can modify code and files directly", - "Code and files access is on by default", - "No automatic system actions", - ], - }, - { - id: "collaborative", - title: "Collaborative", - description: "Acts with approval.", - details: [ - "Can modify code and files directly", - "Runs system actions with approval", - "Uses web access for context", - ], - }, - { - id: "autonomous", - title: "Autonomous", - description: "Acts independently.", - details: [ - "Can modify your codebase directly", - "Can operate your system automatically", - "Uses web access while iterating", - ], - }, -]; - -const STEP_HEADER_COPY: Record< - "starter" | "control" | "customize", - { title: string; subtext: string } -> = { - starter: { - title: "Define Ownership", - subtext: "Assign full accountability.", - }, - control: { - title: "Set Authority Level", - subtext: "Define how independently this agent can act.", - }, - customize: { - title: "Launch Agent", - subtext: "Review mandate and activate.", - }, -}; - -const DIRECTION_DEFAULT_NAMES: Record = { - execution: "Execution Operator", - product: "Product Builder", - growth: "Growth Engine", - revenue: "Revenue Operator", - systems: "Systems Operator", - strategy: "Strategy Lead", -}; - -const isGenericSuggestedName = (value: string): boolean => - /^new agent(?:\s+\d+)?$/i.test(value.trim()); - -const setGroupCapability = (params: { - controls: GuidedCreationControls; - group: string; - enabled: boolean; -}): GuidedCreationControls => { - const nextAllow = new Set(params.controls.toolsAllow); - const nextDeny = new Set(params.controls.toolsDeny); - if (params.enabled) { - nextAllow.add(params.group); - nextDeny.delete(params.group); - } else { - nextDeny.add(params.group); - nextAllow.delete(params.group); - } - return { - ...params.controls, - toolsAllow: Array.from(nextAllow), - toolsDeny: Array.from(nextDeny), - }; +const resolveInitialName = (suggestedName: string): string => { + const trimmed = suggestedName.trim(); + if (!trimmed) return "New Agent"; + return trimmed; }; export const AgentCreateModal = ({ @@ -292,272 +34,26 @@ export const AgentCreateModal = ({ onClose, onSubmit, }: AgentCreateModalProps) => { - const defaultDirection = STARTER_DIRECTIONS[0]; - const initialSuggestedName = - DIRECTION_DEFAULT_NAMES[defaultDirection?.id ?? "execution"] ?? "Execution Operator"; - const initialName = isGenericSuggestedName(suggestedName) - ? initialSuggestedName - : suggestedName.trim() || initialSuggestedName; - const [stepIndex, setStepIndex] = useState(0); - const [name, setName] = useState(() => initialName); - const [nameWasEdited, setNameWasEdited] = useState(false); - const [guidedDraft, setGuidedDraft] = useState(() => { - const seed = createDefaultGuidedDraft(); - if (!defaultDirection) return seed; - return resolveGuidedDraftFromPresetBundle({ - bundle: defaultDirection.bundle, - seed, - }); - }); - const [selectedDirectionId, setSelectedDirectionId] = useState( - defaultDirection?.id ?? "execution" - ); + const initialName = useMemo(() => resolveInitialName(suggestedName), [suggestedName]); + const [name, setName] = useState(initialName); const [avatarSeed, setAvatarSeed] = useState(() => randomUUID()); - const [showCapabilityOverrides, setShowCapabilityOverrides] = useState(false); + const wasOpenRef = useRef(false); - const compiledGuided = useMemo( - () => compileGuidedAgentCreation({ name, draft: guidedDraft }), - [guidedDraft, name] - ); + useEffect(() => { + if (open && !wasOpenRef.current) { + setName(initialName); + setAvatarSeed(randomUUID()); + } + wasOpenRef.current = open; + }, [initialName, open]); - const steps = ["starter", "control", "customize"] as const; - const stepKey = steps[stepIndex] ?? "starter"; - const stepHeader = STEP_HEADER_COPY[stepKey]; - - const canGoNext = - stepKey === "starter" - ? Boolean(guidedDraft.starterKit) - : stepKey === "control" - ? true - : stepKey === "customize" - ? false - : false; - - const canSubmit = - stepKey === "customize" && - name.trim().length > 0 && - compiledGuided.validation.errors.length === 0; - - const moveNext = () => { - if (!canGoNext) return; - setStepIndex((current) => Math.min(steps.length - 1, current + 1)); - }; - - const moveBack = () => { - setStepIndex((current) => Math.max(0, current - 1)); - }; - - const updatePresetBundle = (direction: StarterDirectionTile) => { - setSelectedDirectionId(direction.id); - setName((current) => { - if (nameWasEdited) return current; - return DIRECTION_DEFAULT_NAMES[direction.id] ?? current; - }); - setGuidedDraft((current) => ({ - ...resolveGuidedDraftFromPresetBundle({ - bundle: direction.bundle, - seed: current, - }), - })); - }; - - const webAccessEnabled = hasGuidedGroupCapability({ - controls: guidedDraft.controls, - group: "group:web", - }); - const commandMode: CommandMode = !guidedDraft.controls.allowExec - ? "off" - : guidedDraft.controls.execAutonomy === "auto" - ? "auto" - : "ask-first"; - const selectedAutonomyProfile: AutonomyProfileId = - commandMode === "auto" - ? "autonomous" - : commandMode === "ask-first" - ? "collaborative" - : "conservative"; - const selectedDirection = - STARTER_DIRECTIONS.find((direction) => direction.id === selectedDirectionId) ?? - STARTER_DIRECTIONS[0]; - const authoritySummary = - selectedAutonomyProfile === "autonomous" - ? "Autonomous - acts independently" - : selectedAutonomyProfile === "collaborative" - ? "Collaborative - acts with approval" - : "Conservative - acts with review required"; - - const updateWebAccess = (enabled: boolean) => { - setGuidedDraft((current) => ({ - ...current, - controls: setGroupCapability({ - controls: current.controls, - group: "group:web", - enabled, - }), - })); - }; - - const updateFileMode = (mode: FileMode) => { - if (mode === "off") return; - setGuidedDraft((current) => { - let controls = setGroupCapability({ - controls: current.controls, - group: "group:fs", - enabled: true, - }); - controls = { - ...controls, - fileEditAutonomy: "auto-edit", - workspaceAccess: "rw", - }; - return { - ...current, - controls, - }; - }); - }; - - const updateCommandMode = (mode: CommandMode) => { - setGuidedDraft((current) => { - if (mode === "off") { - return { - ...current, - controls: { - ...current.controls, - allowExec: false, - execAutonomy: "ask-first", - sandboxMode: "off", - }, - }; - } - if (mode === "ask-first") { - return { - ...current, - controls: { - ...current.controls, - allowExec: true, - execAutonomy: "ask-first", - approvalSecurity: "allowlist", - approvalAsk: "always", - sandboxMode: "off", - }, - }; - } - return { - ...current, - controls: { - ...current.controls, - allowExec: true, - execAutonomy: "auto", - approvalSecurity: "full", - approvalAsk: "off", - sandboxMode: "off", - }, - }; - }); - }; - - const applyAutonomyProfile = (profile: AutonomyProfileId) => { - setGuidedDraft((current) => { - if (profile === "conservative") { - let controls = resolveGuidedControlsForPreset({ - starterKit: current.starterKit, - controlLevel: "conservative", - }); - controls = setGroupCapability({ - controls, - group: "group:web", - enabled: true, - }); - controls = setGroupCapability({ - controls, - group: "group:fs", - enabled: true, - }); - controls = { - ...controls, - fileEditAutonomy: "auto-edit", - workspaceAccess: "rw", - allowExec: false, - execAutonomy: "ask-first", - sandboxMode: "off", - }; - return { - ...current, - controlLevel: "conservative", - controls, - }; - } - - if (profile === "collaborative") { - let controls = resolveGuidedControlsForPreset({ - starterKit: current.starterKit, - controlLevel: "balanced", - }); - controls = setGroupCapability({ - controls, - group: "group:web", - enabled: true, - }); - controls = setGroupCapability({ - controls, - group: "group:fs", - enabled: true, - }); - controls = { - ...controls, - fileEditAutonomy: "auto-edit", - workspaceAccess: "rw", - allowExec: true, - execAutonomy: "ask-first", - approvalSecurity: "allowlist", - approvalAsk: "always", - sandboxMode: "off", - }; - return { - ...current, - controlLevel: "balanced", - controls, - }; - } - - let controls = resolveGuidedControlsForPreset({ - starterKit: current.starterKit, - controlLevel: "autopilot", - }); - controls = setGroupCapability({ - controls, - group: "group:web", - enabled: true, - }); - controls = setGroupCapability({ - controls, - group: "group:fs", - enabled: true, - }); - controls = { - ...controls, - fileEditAutonomy: "auto-edit", - workspaceAccess: "rw", - allowExec: true, - execAutonomy: "auto", - approvalSecurity: "full", - approvalAsk: "off", - sandboxMode: "off", - }; - return { - ...current, - controlLevel: "autopilot", - controls, - }; - }); - }; + const canSubmit = name.trim().length > 0; const handleSubmit = () => { - if (!canSubmit) return; + if (!canSubmit || busy) return; const trimmedName = name.trim(); if (!trimmedName) return; - void onSubmit({ mode: "guided", name: trimmedName, draft: guidedDraft, avatarSeed }); + void onSubmit({ name: trimmedName, avatarSeed }); }; if (!open) return null; @@ -570,8 +66,12 @@ export const AgentCreateModal = ({ aria-label="Create agent" onClick={busy ? undefined : onClose} > -
{ + event.preventDefault(); + handleSubmit(); + }} onClick={(event) => event.stopPropagation()} data-testid="agent-create-modal" > @@ -580,8 +80,8 @@ export const AgentCreateModal = ({
New Agent
-
{stepHeader.title}
-
{stepHeader.subtext}
+
Launch Agent
+
Name it and activate immediately.
-
- {stepKey === "starter" ? ( -
-
- What does this agent fully own? -
-
- {STARTER_DIRECTIONS.map((direction) => { - const Icon = direction.icon; - const isSelected = selectedDirectionId === direction.id; - return ( - - ); - })} -
-
-
- This agent will be accountable for: -
-
    - {selectedDirection.accountabilityPoints.map((point) => ( -
  • {point}
  • - ))} -
-
-
- ) : null} - - {stepKey === "control" ? ( -
-
- {AUTONOMY_PROFILES.map((profile) => ( - - ))} -
- - - - {showCapabilityOverrides ? ( -
-
-
Web Access
-
Internet research and fetch tools.
-
- - -
-
- -
-
Code and Files
-
Controls codebase and file modification behavior.
-
- -
-
- Can modify your codebase directly. -
-
- -
-
System Access
-
Controls system actions and command execution.
-
- - - -
-
- {commandMode === "auto" ? "Can operate your system automatically." : "\u00A0"} -
-
-
- ) : null} -
- ) : null} - - {stepKey === "customize" ? ( -
-
-
-
Activation begins immediately.
-
-
-
This agent will:
-
-
- Own: - - {selectedDirection.title} - {selectedDirection.description.replace(/^Owns\s+/i, "").replace(/\.$/, "")} - -
-
- Authority: - {authoritySummary} -
-
-
On launch it will:
-
    - {selectedDirection.launchActions.map((action) => ( -
  • {action}
  • - ))} -
-
- -
- Name reflects its role. You can change it later. -
-
-
Choose avatar
- - -
- You can rename or change the avatar later. -
-
-
-
- ) : null} +
+ +
+ You can rename this agent later in settings. +
+
+
Choose avatar
+ + +
{submitError ? ( -
+
{submitError}
) : null}
- {stepKey === "customize" ? ( -
- ) : ( -
- Step {stepIndex + 1} of {steps.length} -
- )} - {stepKey === "customize" ? ( -
-
- You can adjust ownership and authority later. -
-
- - -
-
- ) : ( -
- -
- )} +
Authority can be configured after launch.
+
-
+
); }; diff --git a/src/features/agents/creation/compiler.ts b/src/features/agents/creation/compiler.ts deleted file mode 100644 index f2e93f9..0000000 --- a/src/features/agents/creation/compiler.ts +++ /dev/null @@ -1,547 +0,0 @@ -import type { AgentFileName } from "@/lib/agents/agentFiles"; -import type { - AgentControlLevel, - AgentPresetBundle, - GuidedPresetBundleDefinition, - GuidedPresetCapabilitySummary, - AgentStarterKit, - GuidedAgentCreationCompileResult, - GuidedAgentCreationDraft, - GuidedCreationControls, -} from "@/features/agents/creation/types"; - -const normalizeLineList = (values: string[]): string[] => { - const next = values - .map((value) => value.trim()) - .filter((value) => value.length > 0); - return Array.from(new Set(next)); -}; - -const renderList = (values: string[], marker: "-" | "1"): string => { - if (marker === "1") { - return values.map((value, index) => `${index + 1}. ${value}`).join("\n"); - } - return values.map((value) => `- ${value}`).join("\n"); -}; - -const firstNonEmpty = (value: string, fallback: string): string => { - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : fallback; -}; - -const defaultHeartbeatChecklist = [ - "Check for open blockers tied to my goal.", - "List one next action if attention is required.", - "If nothing needs attention, reply HEARTBEAT_OK.", -]; - -type StarterTemplate = { - label: string; - role: string; - identityCreature: string; - identityVibe: string; - identityTagline: string; - identityEmoji: string; - soulCoreTruths: string[]; - soulBoundaries: string[]; - soulVibe: string[]; - soulContinuity: string[]; - toolsProfile: GuidedCreationControls["toolsProfile"]; - allowExecByDefault: boolean; - baseAlsoAllow: string[]; - baseDeny: string[]; -}; - -const STARTER_TEMPLATES: Record = { - researcher: { - label: "Researcher", - role: "Research analyst", - identityCreature: "Analyst Cartographer", - identityVibe: "Calm, methodical, and explicit about uncertainty.", - identityTagline: "I turn messy inputs into decision-ready briefs.", - identityEmoji: ":microscope:", - soulCoreTruths: [ - "Evidence beats intuition when stakes are non-trivial.", - "Unknowns should be visible, not hidden.", - "A concise synthesis is more useful than a long dump.", - ], - soulBoundaries: [ - "Do not invent sources, quotes, or confidence.", - "Separate facts from interpretation.", - "Call out when data is stale or incomplete.", - ], - soulVibe: [ - "Structured and low-drama.", - "Specific over broad.", - "Neutral tone with clear tradeoffs.", - ], - soulContinuity: [ - "Track recurring research domains and preferred source quality.", - "Preserve decision criteria used in prior comparisons.", - "Update assumptions when new evidence arrives.", - ], - toolsProfile: "minimal", - allowExecByDefault: false, - baseAlsoAllow: ["group:web"], - baseDeny: ["group:runtime"], - }, - engineer: { - label: "Software Engineer", - role: "Software engineer", - identityCreature: "Pragmatic Builder", - identityVibe: "Direct, test-minded, and minimal-diff focused.", - identityTagline: "I ship small safe changes with proof.", - identityEmoji: ":wrench:", - soulCoreTruths: [ - "Correctness and reversibility come before speed.", - "Small scoped changes reduce operational risk.", - "Tests are behavior contracts, not ceremony.", - ], - soulBoundaries: [ - "Do not run risky or destructive actions without explicit need.", - "Do not hide uncertainty around side effects.", - "Avoid broad refactors unless clearly justified.", - ], - soulVibe: [ - "Concise and implementation-first.", - "File-level specificity.", - "Tradeoffs stated plainly.", - ], - soulContinuity: [ - "Preserve local conventions and architecture patterns.", - "Keep a running map of touched files and rationale.", - "Record follow-up debt discovered during implementation.", - ], - toolsProfile: "coding", - allowExecByDefault: true, - baseAlsoAllow: ["group:web"], - baseDeny: [], - }, - marketer: { - label: "Digital Marketer", - role: "Marketing operator", - identityCreature: "Signal Operator", - identityVibe: "Audience-aware, conversion-focused, and concise.", - identityTagline: "I turn positioning into assets that move metrics.", - identityEmoji: ":chart_with_upwards_trend:", - soulCoreTruths: [ - "Message-market fit beats channel hacks.", - "Clarity outperforms cleverness.", - "Every asset should tie to a measurable outcome.", - ], - soulBoundaries: [ - "Do not publish, send, or launch externally without approval.", - "Do not claim performance without supporting data.", - "Avoid one-size-fits-all messaging.", - ], - soulVibe: [ - "Sharp and practical.", - "Customer-language over internal jargon.", - "Actionable recommendations with expected impact.", - ], - soulContinuity: [ - "Track audience segments, objections, and winning angles.", - "Keep message hierarchies consistent across assets.", - "Preserve experiment outcomes and learnings.", - ], - toolsProfile: "messaging", - allowExecByDefault: false, - baseAlsoAllow: ["group:web"], - baseDeny: ["group:runtime"], - }, - "chief-of-staff": { - label: "Chief of Staff", - role: "Operations coordinator", - identityCreature: "Execution Conductor", - identityVibe: "Structured, deadline-aware, and escalation-ready.", - identityTagline: "I keep priorities aligned and follow-through tight.", - identityEmoji: ":clipboard:", - soulCoreTruths: [ - "Clarity of ownership prevents drift.", - "Cadence creates momentum.", - "Blockers should surface early.", - ], - soulBoundaries: [ - "Do not invent commitments, deadlines, or decisions.", - "Do not hide unresolved blockers.", - "Avoid overloading plans with low-value detail.", - ], - soulVibe: [ - "Calm, organized, and decisive.", - "Status in plain language.", - "Next actions always explicit.", - ], - soulContinuity: [ - "Maintain active priorities, owners, and due dates.", - "Track recurring blockers and escalation paths.", - "Preserve meeting decisions and follow-up history.", - ], - toolsProfile: "minimal", - allowExecByDefault: false, - baseAlsoAllow: ["group:web"], - baseDeny: ["group:runtime"], - }, - blank: { - label: "Blank Starter", - role: "General assistant", - identityCreature: "General Operator", - identityVibe: "Practical, adaptable, and transparent.", - identityTagline: "I bring structure to ambiguous tasks.", - identityEmoji: ":compass:", - soulCoreTruths: [ - "Useful output beats perfect output.", - "Assumptions should be surfaced early.", - "Clear next steps reduce back-and-forth.", - ], - soulBoundaries: [ - "Do not take irreversible actions without confirmation.", - "Do not present guesses as facts.", - "Avoid unnecessary complexity.", - ], - soulVibe: [ - "Direct and low-friction.", - "Context-aware without overexplaining.", - "Pragmatic sequencing.", - ], - soulContinuity: [ - "Retain stable preferences and operating constraints.", - "Track unfinished work and open questions.", - "Keep response style consistent across sessions.", - ], - toolsProfile: "minimal", - allowExecByDefault: false, - baseAlsoAllow: ["group:web"], - baseDeny: ["group:runtime"], - }, -}; - -type ControlDefaults = { - execAutonomy: GuidedCreationControls["execAutonomy"]; - fileEditAutonomy: GuidedCreationControls["fileEditAutonomy"]; - sandboxMode: GuidedCreationControls["sandboxMode"]; - workspaceAccess: GuidedCreationControls["workspaceAccess"]; - approvalSecurity: GuidedCreationControls["approvalSecurity"]; - approvalAsk: GuidedCreationControls["approvalAsk"]; -}; - -const CONTROL_DEFAULTS: Record = { - conservative: { - execAutonomy: "ask-first", - fileEditAutonomy: "auto-edit", - sandboxMode: "off", - workspaceAccess: "rw", - approvalSecurity: "allowlist", - approvalAsk: "always", - }, - balanced: { - execAutonomy: "ask-first", - fileEditAutonomy: "auto-edit", - sandboxMode: "off", - workspaceAccess: "rw", - approvalSecurity: "allowlist", - approvalAsk: "on-miss", - }, - autopilot: { - execAutonomy: "auto", - fileEditAutonomy: "auto-edit", - sandboxMode: "off", - workspaceAccess: "rw", - approvalSecurity: "full", - approvalAsk: "off", - }, -}; - -export const GUIDED_PRESET_BUNDLES: GuidedPresetBundleDefinition[] = [ - { - id: "research-analyst", - group: "knowledge", - title: "Research Analyst", - description: "Evidence-first synthesis with broad access defaults.", - starterKit: "researcher", - controlLevel: "autopilot", - }, - { - id: "pr-engineer", - group: "builder", - title: "PR Engineer", - description: "Safe code changes with broad execution defaults.", - starterKit: "engineer", - controlLevel: "autopilot", - }, - { - id: "autonomous-engineer", - group: "builder", - title: "Autonomous Engineer", - description: "High-autonomy coding with broad execution permissions.", - starterKit: "engineer", - controlLevel: "autopilot", - }, - { - id: "growth-operator", - group: "operations", - title: "Growth Operator", - description: "Campaign drafting defaults with broad access.", - starterKit: "marketer", - controlLevel: "autopilot", - }, - { - id: "coordinator", - group: "operations", - title: "Coordinator", - description: "Follow-up and planning support with broad defaults.", - starterKit: "chief-of-staff", - controlLevel: "autopilot", - }, - { - id: "blank", - group: "baseline", - title: "Blank", - description: "General-purpose baseline with broad defaults.", - starterKit: "blank", - controlLevel: "autopilot", - }, -]; - -const PRESET_BUNDLE_BY_ID: Record = { - "research-analyst": GUIDED_PRESET_BUNDLES[0], - "pr-engineer": GUIDED_PRESET_BUNDLES[1], - "autonomous-engineer": GUIDED_PRESET_BUNDLES[2], - "growth-operator": GUIDED_PRESET_BUNDLES[3], - coordinator: GUIDED_PRESET_BUNDLES[4], - blank: GUIDED_PRESET_BUNDLES[5], -}; - -const resolveStarterTemplate = (starterKit: AgentStarterKit): StarterTemplate => - STARTER_TEMPLATES[starterKit] ?? STARTER_TEMPLATES.engineer; - -export const resolveGuidedPresetBundle = ( - bundle: AgentPresetBundle -): GuidedPresetBundleDefinition => PRESET_BUNDLE_BY_ID[bundle] ?? PRESET_BUNDLE_BY_ID["pr-engineer"]; - -export const resolveGuidedControlsForPreset = (params: { - starterKit: AgentStarterKit; - controlLevel: AgentControlLevel; -}): GuidedCreationControls => { - const starter = resolveStarterTemplate(params.starterKit); - const control = CONTROL_DEFAULTS[params.controlLevel]; - const allowExec = params.controlLevel === "autopilot" ? true : starter.allowExecByDefault; - const toolsAllow = new Set(starter.baseAlsoAllow); - toolsAllow.add("group:fs"); - if (params.controlLevel === "autopilot") { - toolsAllow.add("group:web"); - } - return { - allowExec, - execAutonomy: control.execAutonomy, - fileEditAutonomy: control.fileEditAutonomy, - sandboxMode: control.sandboxMode, - workspaceAccess: control.workspaceAccess, - toolsProfile: starter.toolsProfile, - toolsAllow: Array.from(toolsAllow), - toolsDeny: [...starter.baseDeny], - approvalSecurity: control.approvalSecurity, - approvalAsk: control.approvalAsk, - approvalAllowlist: [], - }; -}; - -export const resolveGuidedDraftFromPresetBundle = (params: { - bundle: AgentPresetBundle; - seed: GuidedAgentCreationDraft; -}): GuidedAgentCreationDraft => { - const bundle = resolveGuidedPresetBundle(params.bundle); - return { - ...params.seed, - starterKit: bundle.starterKit, - controlLevel: bundle.controlLevel, - heartbeatEnabled: false, - controls: resolveGuidedControlsForPreset({ - starterKit: bundle.starterKit, - controlLevel: bundle.controlLevel, - }), - }; -}; - -const TOOL_PROFILE_BASE_ENTRIES: Record = { - minimal: ["session_status"], - coding: ["group:fs", "group:runtime", "group:sessions", "group:memory", "image"], - messaging: ["group:messaging", "sessions_list", "sessions_history", "sessions_send", "session_status"], - full: ["*"], -}; - -export const hasGuidedGroupCapability = (params: { - controls: GuidedCreationControls; - group: string; -}): boolean => { - const deny = new Set(normalizeLineList(params.controls.toolsDeny)); - if (deny.has(params.group)) return false; - if (params.controls.toolsProfile === "full") return true; - const allow = new Set([ - ...TOOL_PROFILE_BASE_ENTRIES[params.controls.toolsProfile], - ...normalizeLineList(params.controls.toolsAllow), - ]); - return allow.has("*") || allow.has(params.group); -}; - -export const deriveGuidedPresetCapabilitySummary = (params: { - controls: GuidedCreationControls; -}): GuidedPresetCapabilitySummary => { - const { controls } = params; - const webEnabled = hasGuidedGroupCapability({ controls, group: "group:web" }); - const fileSystemEnabled = hasGuidedGroupCapability({ controls, group: "group:fs" }); - const execEnabled = controls.allowExec; - return { - chips: [ - { id: "command", label: "Command", value: execEnabled ? "On" : "Off", enabled: execEnabled }, - { - id: "web", - label: "Web access", - value: webEnabled ? "On" : "Off", - enabled: webEnabled, - }, - { - id: "files", - label: "File tools", - value: fileSystemEnabled ? "On" : "Off", - enabled: fileSystemEnabled, - }, - ], - }; -}; - -export const createDefaultGuidedDraft = (): GuidedAgentCreationDraft => { - const seed: GuidedAgentCreationDraft = { - starterKit: "engineer", - controlLevel: "balanced", - customInstructions: "", - userProfile: "", - toolNotes: "", - memoryNotes: "", - heartbeatEnabled: false, - heartbeatChecklist: [...defaultHeartbeatChecklist], - controls: resolveGuidedControlsForPreset({ - starterKit: "engineer", - controlLevel: "balanced", - }), - }; - return resolveGuidedDraftFromPresetBundle({ bundle: "pr-engineer", seed }); -}; - -export const compileGuidedAgentCreation = (params: { - name: string; - draft: GuidedAgentCreationDraft; -}): GuidedAgentCreationCompileResult => { - const name = params.name.trim(); - const starter = resolveStarterTemplate(params.draft.starterKit); - - const toolsAllow = normalizeLineList(params.draft.controls.toolsAllow); - const toolsDeny = normalizeLineList(params.draft.controls.toolsDeny); - const approvalAllowlist = normalizeLineList(params.draft.controls.approvalAllowlist).map( - (pattern) => ({ pattern }) - ); - - const ensureToolAlsoAllow = new Set(toolsAllow); - const ensureToolDeny = new Set(toolsDeny); - if (params.draft.controls.allowExec) { - ensureToolAlsoAllow.add("group:runtime"); - ensureToolDeny.delete("group:runtime"); - } else { - ensureToolDeny.add("group:runtime"); - ensureToolAlsoAllow.delete("group:runtime"); - } - ensureToolAlsoAllow.add("group:fs"); - ensureToolDeny.delete("group:fs"); - - const normalizedAlsoAllow = Array.from(ensureToolAlsoAllow); - const normalizedDeny = Array.from(ensureToolDeny).filter( - (entry) => !ensureToolAlsoAllow.has(entry) - ); - const normalizedSandboxMode = "off"; - - const errors: string[] = []; - const warnings: string[] = []; - - if (!name) errors.push("Agent name is required."); - if (params.draft.controls.execAutonomy === "auto" && params.draft.controls.approvalSecurity === "deny") { - errors.push("Auto exec cannot be enabled when approval security is set to deny."); - } - if (params.draft.controls.execAutonomy === "auto" && !params.draft.controls.allowExec) { - errors.push("Auto exec requires runtime tools to be enabled."); - } - - const files: Partial> = { - "SOUL.md": [ - "# SOUL.md - Who You Are", - "", - "## Core Truths", - renderList(starter.soulCoreTruths, "-"), - "", - "## Boundaries", - renderList(starter.soulBoundaries, "-"), - "", - "## Vibe", - renderList(starter.soulVibe, "-"), - "", - "## Continuity", - renderList(starter.soulContinuity, "-"), - ].join("\n"), - "IDENTITY.md": [ - "# IDENTITY.md - Who Am I?", - `- Name: ${firstNonEmpty(name, "New Agent")}`, - `- Role: ${starter.role}`, - `- Creature: ${starter.identityCreature}`, - `- Vibe: ${starter.identityVibe}`, - `- Emoji: ${starter.identityEmoji}`, - `- Identity: ${starter.identityTagline}`, - `- Starter kit: ${starter.label}`, - ].join("\n"), - }; - - const webAccessEnabled = hasGuidedGroupCapability({ - controls: params.draft.controls, - group: "group:web", - }); - const sandboxSummary = "Sessions run without sandbox isolation."; - const fileSummary = "Can apply file edits directly on the host filesystem."; - const commandSummary = !params.draft.controls.allowExec - ? "Command execution is disabled." - : params.draft.controls.execAutonomy === "auto" - ? "Can run commands automatically without approval prompts." - : "Can run commands with approval prompts."; - - const summary = [ - `Starter: ${starter.label}`, - "Persona files: custom IDENTITY.md + SOUL.md; AGENTS.md remains the gateway default.", - webAccessEnabled ? "Web access is enabled for search and fetch tools." : "Web access is disabled.", - fileSummary, - commandSummary, - sandboxSummary, - ]; - - return { - files, - agentOverrides: { - sandbox: { - mode: normalizedSandboxMode, - workspaceAccess: params.draft.controls.workspaceAccess, - }, - tools: { - profile: params.draft.controls.toolsProfile, - alsoAllow: normalizedAlsoAllow, - deny: normalizedDeny, - }, - }, - execApprovals: params.draft.controls.allowExec - ? { - security: params.draft.controls.approvalSecurity, - ask: params.draft.controls.approvalAsk, - allowlist: approvalAllowlist, - } - : null, - validation: { - errors, - warnings, - }, - summary, - }; -}; diff --git a/src/features/agents/creation/pendingGuidedSetupSessionStorageLifecycle.ts b/src/features/agents/creation/pendingGuidedSetupSessionStorageLifecycle.ts deleted file mode 100644 index 44a7bc2..0000000 --- a/src/features/agents/creation/pendingGuidedSetupSessionStorageLifecycle.ts +++ /dev/null @@ -1,37 +0,0 @@ -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { - loadPendingGuidedSetupsFromStorage, - persistPendingGuidedSetupsToStorage, -} from "@/features/agents/creation/pendingSetupStore"; - -export const loadPendingGuidedSetupsForScope = (params: { - storage: Storage | null | undefined; - gatewayScope: string; -}): { - setupsByAgentId: Record; - loadedScope: string; -} => { - const setupsByAgentId = loadPendingGuidedSetupsFromStorage({ - storage: params.storage, - gatewayScope: params.gatewayScope, - }); - return { - setupsByAgentId, - loadedScope: params.gatewayScope, - }; -}; - -export const persistPendingGuidedSetupsForScopeWhenLoaded = (params: { - storage: Storage | null | undefined; - gatewayScope: string; - loadedScope: string | null | undefined; - setupsByAgentId: Record; -}): void => { - if (params.loadedScope !== params.gatewayScope) return; - persistPendingGuidedSetupsToStorage({ - storage: params.storage, - gatewayScope: params.gatewayScope, - setupsByAgentId: params.setupsByAgentId, - }); -}; - diff --git a/src/features/agents/creation/pendingSetupRetry.ts b/src/features/agents/creation/pendingSetupRetry.ts deleted file mode 100644 index cf9709f..0000000 --- a/src/features/agents/creation/pendingSetupRetry.ts +++ /dev/null @@ -1,38 +0,0 @@ -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; - -type RetrySelectionParams = { - pendingSetupsByAgentId: Record; - knownAgentIds: Set; - attemptedAgentIds: Set; - inFlightAgentIds: Set; -}; - -export const selectNextPendingGuidedSetupRetryAgentId = ( - params: RetrySelectionParams -): string | null => { - const orderedIds = Object.keys(params.pendingSetupsByAgentId) - .map((agentId) => agentId.trim()) - .filter((agentId) => agentId.length > 0) - .sort(); - for (const agentId of orderedIds) { - if (!params.knownAgentIds.has(agentId)) continue; - if (params.attemptedAgentIds.has(agentId)) continue; - if (params.inFlightAgentIds.has(agentId)) continue; - return agentId; - } - return null; -}; - -export const beginPendingGuidedSetupRetry = (inFlightAgentIds: Set, agentId: string): boolean => { - const resolvedAgentId = agentId.trim(); - if (!resolvedAgentId) return false; - if (inFlightAgentIds.has(resolvedAgentId)) return false; - inFlightAgentIds.add(resolvedAgentId); - return true; -}; - -export const endPendingGuidedSetupRetry = (inFlightAgentIds: Set, agentId: string): void => { - const resolvedAgentId = agentId.trim(); - if (!resolvedAgentId) return; - inFlightAgentIds.delete(resolvedAgentId); -}; diff --git a/src/features/agents/creation/pendingSetupStore.ts b/src/features/agents/creation/pendingSetupStore.ts deleted file mode 100644 index 80555aa..0000000 --- a/src/features/agents/creation/pendingSetupStore.ts +++ /dev/null @@ -1,160 +0,0 @@ -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; - -export const PENDING_GUIDED_SETUP_SESSION_KEY = "openclaw.studio.pending-guided-setups.v1"; -export const PENDING_GUIDED_SETUP_STORE_VERSION = 1; -export const PENDING_GUIDED_SETUP_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000; - -type SerializedPendingGuidedSetupEntry = { - agentId: string; - gatewayScope: string; - setup: AgentGuidedSetup; - savedAtMs: number; -}; - -type SerializedPendingGuidedSetupStore = { - version: 1; - entries: SerializedPendingGuidedSetupEntry[]; -}; - -const isRecord = (value: unknown): value is Record => - Boolean(value && typeof value === "object" && !Array.isArray(value)); - -const asFiniteNumber = (value: unknown): number | null => - typeof value === "number" && Number.isFinite(value) ? value : null; - -const parseAgentId = (value: unknown): string | null => { - if (typeof value !== "string") return null; - const trimmed = value.trim(); - return trimmed.length > 0 ? trimmed : null; -}; - -export const normalizePendingGuidedSetupGatewayScope = ( - value: unknown -): string => { - if (typeof value !== "string") return ""; - return value.trim().toLowerCase(); -}; - -const isGuidedSetup = (value: unknown): value is AgentGuidedSetup => { - if (!isRecord(value)) return false; - if (!isRecord(value.agentOverrides)) return false; - if (!isRecord(value.files)) return false; - const execApprovals = value.execApprovals; - if (execApprovals !== null && execApprovals !== undefined && !isRecord(execApprovals)) { - return false; - } - return true; -}; - -const readStorageItem = (storage: Storage, key: string): string | null => { - try { - return storage.getItem(key); - } catch (err) { - console.warn(`Failed to read pending guided setup store "${key}".`, err); - return null; - } -}; - -const writeStorageItem = (storage: Storage, key: string, value: string): void => { - try { - storage.setItem(key, value); - } catch (err) { - console.warn(`Failed to write pending guided setup store "${key}".`, err); - } -}; - -const removeStorageItem = (storage: Storage, key: string): void => { - try { - storage.removeItem(key); - } catch (err) { - console.warn(`Failed to remove pending guided setup store "${key}".`, err); - } -}; - -const parseStoreEntries = (raw: string, params: { nowMs: number; maxAgeMs: number }) => { - let parsed: unknown; - try { - parsed = JSON.parse(raw) as unknown; - } catch { - return [] as SerializedPendingGuidedSetupEntry[]; - } - if (!isRecord(parsed) || parsed.version !== PENDING_GUIDED_SETUP_STORE_VERSION) { - return [] as SerializedPendingGuidedSetupEntry[]; - } - const entriesRaw = Array.isArray(parsed.entries) ? parsed.entries : []; - const next: SerializedPendingGuidedSetupEntry[] = []; - for (const entry of entriesRaw) { - if (!isRecord(entry)) continue; - const agentId = parseAgentId(entry.agentId); - const gatewayScope = normalizePendingGuidedSetupGatewayScope(entry.gatewayScope); - const savedAtMs = asFiniteNumber(entry.savedAtMs); - if (!agentId || savedAtMs === null || savedAtMs < params.nowMs - params.maxAgeMs) continue; - if (!isGuidedSetup(entry.setup)) continue; - next.push({ - agentId, - gatewayScope, - setup: entry.setup, - savedAtMs, - }); - } - return next; -}; - -export const loadPendingGuidedSetupsFromStorage = (params: { - storage: Storage | null | undefined; - gatewayScope?: string | null; - nowMs?: number; - maxAgeMs?: number; -}): Record => { - if (!params.storage) return {}; - const raw = readStorageItem(params.storage, PENDING_GUIDED_SETUP_SESSION_KEY); - if (!raw) return {}; - const gatewayScope = normalizePendingGuidedSetupGatewayScope(params.gatewayScope); - const entries = parseStoreEntries(raw, { - nowMs: params.nowMs ?? Date.now(), - maxAgeMs: params.maxAgeMs ?? PENDING_GUIDED_SETUP_MAX_AGE_MS, - }); - const next: Record = {}; - for (const entry of entries) { - if (entry.gatewayScope !== gatewayScope) continue; - next[entry.agentId] = entry.setup; - } - return next; -}; - -export const persistPendingGuidedSetupsToStorage = (params: { - storage: Storage | null | undefined; - gatewayScope?: string | null; - setupsByAgentId: Record; - nowMs?: number; -}): void => { - if (!params.storage) return; - const nowMs = params.nowMs ?? Date.now(); - const gatewayScope = normalizePendingGuidedSetupGatewayScope(params.gatewayScope); - const raw = readStorageItem(params.storage, PENDING_GUIDED_SETUP_SESSION_KEY); - const existingEntries = raw - ? parseStoreEntries(raw, { - nowMs, - maxAgeMs: PENDING_GUIDED_SETUP_MAX_AGE_MS, - }) - : []; - const retainedEntries = existingEntries.filter((entry) => entry.gatewayScope !== gatewayScope); - const scopedEntries: SerializedPendingGuidedSetupEntry[] = Object.entries(params.setupsByAgentId) - .map(([agentId, setup]) => ({ - agentId: agentId.trim(), - gatewayScope, - setup, - savedAtMs: nowMs, - })) - .filter((entry) => entry.agentId.length > 0); - const entries = [...retainedEntries, ...scopedEntries]; - if (entries.length === 0) { - removeStorageItem(params.storage, PENDING_GUIDED_SETUP_SESSION_KEY); - return; - } - const payload: SerializedPendingGuidedSetupStore = { - version: PENDING_GUIDED_SETUP_STORE_VERSION, - entries, - }; - writeStorageItem(params.storage, PENDING_GUIDED_SETUP_SESSION_KEY, JSON.stringify(payload)); -}; diff --git a/src/features/agents/creation/recovery.ts b/src/features/agents/creation/recovery.ts deleted file mode 100644 index d4e8766..0000000 --- a/src/features/agents/creation/recovery.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { GatewayClient } from "@/lib/gateway/GatewayClient"; -import { - applyGuidedAgentSetup, - type AgentGuidedSetup, -} from "@/features/agents/operations/createAgentOperation"; - -export const upsertPendingGuidedSetup = ( - pendingSetupsByAgentId: Record, - agentId: string, - setup: AgentGuidedSetup -): Record => { - const id = agentId.trim(); - if (!id) return pendingSetupsByAgentId; - return { - ...pendingSetupsByAgentId, - [id]: setup, - }; -}; - -export const removePendingGuidedSetup = ( - pendingSetupsByAgentId: Record, - agentId: string -): Record => { - const id = agentId.trim(); - if (!id || !(id in pendingSetupsByAgentId)) { - return pendingSetupsByAgentId; - } - const next = { ...pendingSetupsByAgentId }; - delete next[id]; - return next; -}; - -export const applyPendingGuidedSetupForAgent = async (params: { - client: GatewayClient; - agentId: string; - pendingSetupsByAgentId: Record; -}): Promise<{ applied: boolean; pendingSetupsByAgentId: Record }> => { - const id = params.agentId.trim(); - if (!id) { - return { - applied: false, - pendingSetupsByAgentId: params.pendingSetupsByAgentId, - }; - } - const setup = params.pendingSetupsByAgentId[id]; - if (!setup) { - return { - applied: false, - pendingSetupsByAgentId: params.pendingSetupsByAgentId, - }; - } - await applyGuidedAgentSetup({ - client: params.client, - agentId: id, - setup, - }); - return { - applied: true, - pendingSetupsByAgentId: removePendingGuidedSetup(params.pendingSetupsByAgentId, id), - }; -}; diff --git a/src/features/agents/creation/types.ts b/src/features/agents/creation/types.ts index 91f8555..31ae46f 100644 --- a/src/features/agents/creation/types.ts +++ b/src/features/agents/creation/types.ts @@ -1,96 +1,4 @@ -import type { AgentFileName } from "@/lib/agents/agentFiles"; -import type { GatewayAgentOverrides } from "@/lib/gateway/agentConfig"; -import type { - GatewayExecApprovalAsk, - GatewayExecApprovalSecurity, -} from "@/lib/gateway/execApprovals"; - -export type AgentStarterKit = - | "researcher" - | "engineer" - | "marketer" - | "chief-of-staff" - | "blank"; -export type AgentControlLevel = "conservative" | "balanced" | "autopilot"; -export type AgentPresetBundle = - | "research-analyst" - | "pr-engineer" - | "autonomous-engineer" - | "growth-operator" - | "coordinator" - | "blank"; -export type AgentPresetBundleGroup = "knowledge" | "builder" | "operations" | "baseline"; -export type GuidedPresetCapabilityChipId = "command" | "web" | "files"; - -export type GuidedPresetCapabilityChip = { - id: GuidedPresetCapabilityChipId; - label: string; - value: string; - enabled: boolean; -}; - -export type GuidedPresetCapabilitySummary = { - chips: GuidedPresetCapabilityChip[]; -}; - -export type GuidedPresetBundleDefinition = { - id: AgentPresetBundle; - group: AgentPresetBundleGroup; - title: string; - description: string; - starterKit: AgentStarterKit; - controlLevel: AgentControlLevel; -}; - -export type GuidedExecAutonomy = "ask-first" | "auto"; -export type GuidedFileEditAutonomy = "propose-only" | "auto-edit"; - -export type GuidedCreationControls = { - allowExec: boolean; - execAutonomy: GuidedExecAutonomy; - fileEditAutonomy: GuidedFileEditAutonomy; - sandboxMode: "off" | "non-main" | "all"; - workspaceAccess: "none" | "ro" | "rw"; - toolsProfile: "minimal" | "coding" | "messaging" | "full"; - toolsAllow: string[]; - toolsDeny: string[]; - approvalSecurity: GatewayExecApprovalSecurity; - approvalAsk: GatewayExecApprovalAsk; - approvalAllowlist: string[]; -}; - -export type GuidedAgentCreationDraft = { - starterKit: AgentStarterKit; - controlLevel: AgentControlLevel; - customInstructions: string; - userProfile: string; - toolNotes: string; - memoryNotes: string; - heartbeatEnabled: boolean; - heartbeatChecklist: string[]; - controls: GuidedCreationControls; -}; - export type AgentCreateModalSubmitPayload = { - mode: "guided"; name: string; - draft: GuidedAgentCreationDraft; avatarSeed?: string; }; - -export type GuidedExecApprovalsPolicy = { - security: GatewayExecApprovalSecurity; - ask: GatewayExecApprovalAsk; - allowlist: Array<{ pattern: string }>; -}; - -export type GuidedAgentCreationCompileResult = { - files: Partial>; - agentOverrides: GatewayAgentOverrides; - execApprovals: GuidedExecApprovalsPolicy | null; - validation: { - errors: string[]; - warnings: string[]; - }; - summary: string[]; -}; diff --git a/src/features/agents/operations/agentMutationLifecycleController.ts b/src/features/agents/operations/agentMutationLifecycleController.ts index a106c6f..4884682 100644 --- a/src/features/agents/operations/agentMutationLifecycleController.ts +++ b/src/features/agents/operations/agentMutationLifecycleController.ts @@ -1,7 +1,3 @@ -import { type AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { shouldAttemptPendingSetupAutoRetry } from "@/features/agents/operations/pendingSetupLifecycleWorkflow"; -import { selectNextPendingGuidedSetupRetryAgentId } from "@/features/agents/creation/pendingSetupRetry"; - export type MutationKind = "create-agent" | "rename-agent" | "delete-agent"; export type MutationBlockPhase = "queued" | "mutating" | "awaiting-restart"; @@ -107,77 +103,6 @@ export const buildMutationSideEffectCommands = (params: { return [{ kind: "patch-mutation-block", patch: postRunIntent.patch }]; }; -export type PendingSetupAutoRetryIntent = - | { - kind: "skip"; - reason: - | "not-connected" - | "agents-not-loaded" - | "scope-mismatch" - | "create-block-active" - | "retry-busy" - | "no-eligible-agent"; - } - | { kind: "retry"; agentId: string }; - -const resolvePendingSetupSkipReason = (params: { - status: "connected" | "connecting" | "disconnected"; - agentsLoadedOnce: boolean; - loadedScopeMatches: boolean; - hasActiveCreateBlock: boolean; - retryBusyAgentId: string | null; -}): PendingSetupAutoRetryIntent => { - if (params.status !== "connected") { - return { kind: "skip", reason: "not-connected" }; - } - if (!params.agentsLoadedOnce) { - return { kind: "skip", reason: "agents-not-loaded" }; - } - if (!params.loadedScopeMatches) { - return { kind: "skip", reason: "scope-mismatch" }; - } - if (params.hasActiveCreateBlock) { - return { kind: "skip", reason: "create-block-active" }; - } - if (params.retryBusyAgentId) { - return { kind: "skip", reason: "retry-busy" }; - } - return { kind: "skip", reason: "no-eligible-agent" }; -}; - -export const resolvePendingSetupAutoRetryIntent = (params: { - status: "connected" | "connecting" | "disconnected"; - agentsLoadedOnce: boolean; - loadedScopeMatches: boolean; - hasActiveCreateBlock: boolean; - retryBusyAgentId: string | null; - pendingSetupsByAgentId: Record; - knownAgentIds: Set; - attemptedAgentIds: Set; - inFlightAgentIds: Set; -}): PendingSetupAutoRetryIntent => { - const shouldAttempt = shouldAttemptPendingSetupAutoRetry({ - status: params.status, - agentsLoadedOnce: params.agentsLoadedOnce, - loadedScopeMatches: params.loadedScopeMatches, - hasActiveCreateBlock: params.hasActiveCreateBlock, - retryBusyAgentId: params.retryBusyAgentId, - }); - if (!shouldAttempt) { - return resolvePendingSetupSkipReason(params); - } - const targetAgentId = selectNextPendingGuidedSetupRetryAgentId({ - pendingSetupsByAgentId: params.pendingSetupsByAgentId as Record, - knownAgentIds: params.knownAgentIds, - attemptedAgentIds: params.attemptedAgentIds, - inFlightAgentIds: params.inFlightAgentIds, - }); - if (!targetAgentId) { - return { kind: "skip", reason: "no-eligible-agent" }; - } - return { kind: "retry", agentId: targetAgentId }; -}; - export type MutationTimeoutIntent = | { kind: "none" } | { kind: "timeout"; reason: "create-timeout" | "rename-timeout" | "delete-timeout" }; diff --git a/src/features/agents/operations/createAgentMutationLifecycleOperation.ts b/src/features/agents/operations/createAgentMutationLifecycleOperation.ts index bbb37b3..dc37eb1 100644 --- a/src/features/agents/operations/createAgentMutationLifecycleOperation.ts +++ b/src/features/agents/operations/createAgentMutationLifecycleOperation.ts @@ -1,28 +1,21 @@ -import { compileGuidedAgentCreation } from "@/features/agents/creation/compiler"; import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types"; -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { - resolveGuidedCreateCompletion, - runGuidedCreateWorkflow, - runGuidedRetryWorkflow, - type GuidedCreateCompletion, -} from "@/features/agents/operations/guidedCreateWorkflow"; -import { applyPendingGuidedSetupRetryViaStudio } from "@/features/agents/operations/pendingGuidedSetupRetryOperation"; import { resolveMutationStartGuard, resolveMutationTimeoutIntent, } from "@/features/agents/operations/agentMutationLifecycleController"; import type { ConfigMutationKind } from "@/features/agents/operations/useConfigMutationQueue"; -type SetState = (next: T | ((current: T) => T)) => void; - export type CreateAgentBlockState = { - agentId: string | null; agentName: string; - phase: "queued" | "creating" | "applying-setup"; + phase: "queued" | "creating"; startedAt: number; }; +export type CreateAgentLifecycleCompletion = { + agentId: string; + agentName: string; +}; + export type CreateAgentMutationLifecycleDeps = { enqueueConfigMutation: (params: { kind: ConfigMutationKind; @@ -30,14 +23,9 @@ export type CreateAgentMutationLifecycleDeps = { run: () => Promise; }) => Promise; createAgent: (name: string, avatarSeed: string | null) => Promise<{ id: string }>; - applySetup: (agentId: string, setup: AgentGuidedSetup) => Promise; - upsertPending: (agentId: string, setup: AgentGuidedSetup) => void; - removePending: (agentId: string) => void; setQueuedBlock: (params: { agentName: string; startedAt: number }) => void; setCreatingBlock: (agentName: string) => void; - setApplyingSetupBlock: (params: { agentName: string; agentId: string }) => void; - onCompletion: (completion: GuidedCreateCompletion) => Promise | void; - setCreateAgentModalOpen: (open: boolean) => void; + onCompletion: (completion: CreateAgentLifecycleCompletion) => Promise | void; setCreateAgentModalError: (message: string | null) => void; setCreateAgentBusy: (busy: boolean) => void; clearCreateBlock: () => void; @@ -53,7 +41,6 @@ export const runCreateAgentMutationLifecycle = async ( hasRenameBlock: boolean; hasDeleteBlock: boolean; createAgentBusy: boolean; - isLocalGateway: boolean; }, deps: CreateAgentMutationLifecycleDeps ): Promise => { @@ -77,17 +64,6 @@ export const runCreateAgentMutationLifecycle = async ( return false; } - const compiled = compileGuidedAgentCreation({ name, draft: params.payload.draft }); - if (compiled.validation.errors.length > 0) { - deps.setCreateAgentModalError(compiled.validation.errors[0] ?? "Guided setup is incomplete."); - return false; - } - const setup: AgentGuidedSetup = { - agentOverrides: compiled.agentOverrides, - files: compiled.files, - execApprovals: compiled.execApprovals, - }; - deps.setCreateAgentBusy(true); deps.setCreateAgentModalError(null); const startedAt = (deps.now ?? Date.now)(); @@ -99,33 +75,13 @@ export const runCreateAgentMutationLifecycle = async ( label: `Create ${name}`, run: async () => { deps.setCreatingBlock(name); - const result = await runGuidedCreateWorkflow( - { - name, - setup, - isLocalGateway: params.isLocalGateway, - }, - { - createAgent: async (agentName) => { - return await deps.createAgent(agentName, avatarSeed); - }, - applySetup: async (agentId, nextSetup) => { - deps.setApplyingSetupBlock({ agentName: name, agentId }); - await deps.applySetup(agentId, nextSetup); - }, - upsertPending: deps.upsertPending, - removePending: deps.removePending, - } - ); - await deps.onCompletion( - resolveGuidedCreateCompletion({ - agentName: name, - result, - }) - ); + const created = await deps.createAgent(name, avatarSeed); + await deps.onCompletion({ + agentId: created.id, + agentName: name, + }); }, }); - deps.setCreateAgentModalOpen(false); await queuedMutation; return true; } catch (error) { @@ -139,39 +95,6 @@ export const runCreateAgentMutationLifecycle = async ( } }; -export const runPendingCreateSetupRetryLifecycle = async (params: { - agentId: string; - source: "auto" | "manual"; - retryBusyAgentId: string | null; - inFlightAgentIds: Set; - pendingSetupsByAgentId: Record; - setRetryBusyAgentId: SetState; - applyPendingSetup: (agentId: string) => Promise<{ applied: boolean }>; - removePending: (agentId: string) => void; - isDisconnectLikeError: (error: unknown) => boolean; - resolveAgentName: (agentId: string) => string; - onApplied: () => Promise | void; - onError: (message: string) => void; -}): Promise => { - return await applyPendingGuidedSetupRetryViaStudio({ - agentId: params.agentId, - source: params.source, - retryBusyAgentId: params.retryBusyAgentId, - inFlightAgentIds: params.inFlightAgentIds, - pendingSetupsByAgentId: params.pendingSetupsByAgentId, - setRetryBusyAgentId: params.setRetryBusyAgentId, - executeRetry: async (agentId) => - runGuidedRetryWorkflow(agentId, { - applyPendingSetup: params.applyPendingSetup, - removePending: params.removePending, - }), - isDisconnectLikeError: params.isDisconnectLikeError, - resolveAgentName: params.resolveAgentName, - onApplied: params.onApplied, - onError: params.onError, - }); -}; - export const isCreateBlockTimedOut = (params: { block: CreateAgentBlockState | null; nowMs: number; @@ -183,7 +106,7 @@ export const isCreateBlockTimedOut = (params: { const timeoutIntent = resolveMutationTimeoutIntent({ block: { kind: "create-agent", - agentId: params.block.agentId ?? "", + agentId: "", agentName: params.block.agentName, phase: "mutating", startedAt: params.block.startedAt, diff --git a/src/features/agents/operations/createAgentOperation.ts b/src/features/agents/operations/createAgentOperation.ts deleted file mode 100644 index a657260..0000000 --- a/src/features/agents/operations/createAgentOperation.ts +++ /dev/null @@ -1,91 +0,0 @@ -import type { AgentFileName } from "@/lib/agents/agentFiles"; -import { - createGatewayAgent, - type GatewayAgentOverrides, - updateGatewayAgentOverrides, -} from "@/lib/gateway/agentConfig"; -import { - upsertGatewayAgentExecApprovals, - type GatewayExecApprovalAsk, - type GatewayExecApprovalSecurity, -} from "@/lib/gateway/execApprovals"; -import { writeGatewayAgentFiles } from "@/lib/gateway/agentFiles"; -import type { GatewayClient } from "@/lib/gateway/GatewayClient"; -import { ensureGatewaySandboxEnvAllowlistFromDotEnv } from "@/lib/gateway/sandboxEnvAllowlist"; - -export type AgentGuidedSetup = { - agentOverrides: GatewayAgentOverrides; - files: Partial>; - execApprovals: - | { - security: GatewayExecApprovalSecurity; - ask: GatewayExecApprovalAsk; - allowlist: Array<{ pattern: string }>; - } - | null; -}; - -export const applyGuidedAgentSetup = async (params: { - client: GatewayClient; - agentId: string; - setup: AgentGuidedSetup; - includeAgentOverrides?: boolean; -}): Promise => { - const agentId = params.agentId.trim(); - if (!agentId) { - throw new Error("Agent id is required."); - } - await ensureGatewaySandboxEnvAllowlistFromDotEnv({ client: params.client }); - await writeGatewayAgentFiles({ - client: params.client, - agentId, - files: params.setup.files, - }); - await upsertGatewayAgentExecApprovals({ - client: params.client, - agentId, - policy: params.setup.execApprovals, - }); - if (params.includeAgentOverrides !== false) { - await updateGatewayAgentOverrides({ - client: params.client, - agentId, - overrides: params.setup.agentOverrides, - }); - } -}; - -export const createAgentWithOptionalSetup = async (params: { - client: GatewayClient; - name: string; - setup: AgentGuidedSetup | null; - isLocalGateway: boolean; -}): Promise<{ - agentId: string; - setupApplied: boolean; - awaitingRestart: boolean; -}> => { - const created = await createGatewayAgent({ - client: params.client, - name: params.name, - }); - if (params.isLocalGateway) { - if (params.setup) { - await applyGuidedAgentSetup({ - client: params.client, - agentId: created.id, - setup: params.setup, - }); - } - return { - agentId: created.id, - setupApplied: Boolean(params.setup), - awaitingRestart: false, - }; - } - return { - agentId: created.id, - setupApplied: false, - awaitingRestart: true, - }; -}; diff --git a/src/features/agents/operations/guidedCreateWorkflow.ts b/src/features/agents/operations/guidedCreateWorkflow.ts deleted file mode 100644 index 53dbdb6..0000000 --- a/src/features/agents/operations/guidedCreateWorkflow.ts +++ /dev/null @@ -1,111 +0,0 @@ -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; - -export type GuidedCreateWorkflowDeps = { - createAgent: (name: string) => Promise<{ id: string }>; - applySetup: (agentId: string, setup: AgentGuidedSetup) => Promise; - upsertPending: (agentId: string, setup: AgentGuidedSetup) => void; - removePending: (agentId: string) => void; -}; - -export type GuidedCreateWorkflowInput = { - name: string; - setup: AgentGuidedSetup; - isLocalGateway: boolean; -}; - -export type GuidedCreateWorkflowResult = { - agentId: string; - setupStatus: "applied" | "pending"; - setupErrorMessage: string | null; -}; - -export type GuidedRetryWorkflowDeps = { - applyPendingSetup: (agentId: string) => Promise<{ applied: boolean }>; - removePending: (agentId: string) => void; -}; - -export type GuidedRetryWorkflowResult = { - applied: boolean; -}; - -export type GuidedCreateCompletion = { - shouldReloadAgents: true; - shouldCloseCreateModal: true; - pendingErrorMessage: string | null; -}; - -const resolveErrorMessage = (error: unknown): string => - error instanceof Error ? error.message : "Agent setup failed."; - -const resolveAgentId = (value: string): string => { - const id = value.trim(); - if (!id) { - throw new Error("Agent id is required."); - } - return id; -}; - -export const runGuidedCreateWorkflow = async ( - input: GuidedCreateWorkflowInput, - deps: GuidedCreateWorkflowDeps -): Promise => { - const name = input.name.trim(); - if (!name) { - throw new Error("Agent name is required."); - } - - const created = await deps.createAgent(name); - const agentId = resolveAgentId(created.id); - if (!input.isLocalGateway) { - deps.upsertPending(agentId, input.setup); - } - - try { - await deps.applySetup(agentId, input.setup); - deps.removePending(agentId); - return { - agentId, - setupStatus: "applied", - setupErrorMessage: null, - }; - } catch (error) { - if (input.isLocalGateway) { - deps.upsertPending(agentId, input.setup); - } - return { - agentId, - setupStatus: "pending", - setupErrorMessage: resolveErrorMessage(error), - }; - } -}; - -export const resolveGuidedCreateCompletion = (params: { - agentName: string; - result: GuidedCreateWorkflowResult; -}): GuidedCreateCompletion => { - const fallbackSetupErrorMessage = "Agent setup failed."; - const pendingErrorMessage = - params.result.setupStatus === "pending" - ? `Agent "${params.agentName}" was created, but guided setup is pending. Retry or discard setup from chat. ${ - params.result.setupErrorMessage?.trim() || fallbackSetupErrorMessage - }` - : null; - return { - shouldReloadAgents: true, - shouldCloseCreateModal: true, - pendingErrorMessage, - }; -}; - -export const runGuidedRetryWorkflow = async ( - agentId: string, - deps: GuidedRetryWorkflowDeps -): Promise => { - const resolvedAgentId = resolveAgentId(agentId); - const result = await deps.applyPendingSetup(resolvedAgentId); - if (result.applied) { - deps.removePending(resolvedAgentId); - } - return { applied: result.applied }; -}; diff --git a/src/features/agents/operations/pendingGuidedSetupAutoRetryOperation.ts b/src/features/agents/operations/pendingGuidedSetupAutoRetryOperation.ts deleted file mode 100644 index 506b5db..0000000 --- a/src/features/agents/operations/pendingGuidedSetupAutoRetryOperation.ts +++ /dev/null @@ -1,33 +0,0 @@ -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { resolvePendingSetupAutoRetryIntent } from "@/features/agents/operations/agentMutationLifecycleController"; - -export const runPendingGuidedSetupAutoRetryViaStudio = async (params: { - status: "connected" | "connecting" | "disconnected"; - agentsLoadedOnce: boolean; - loadedScopeMatches: boolean; - hasActiveCreateBlock: boolean; - retryBusyAgentId: string | null; - pendingSetupsByAgentId: Record; - knownAgentIds: Set; - attemptedAgentIds: Set; - inFlightAgentIds: Set; - applyRetry: (agentId: string) => Promise; -}): Promise => { - const intent = resolvePendingSetupAutoRetryIntent({ - status: params.status, - agentsLoadedOnce: params.agentsLoadedOnce, - loadedScopeMatches: params.loadedScopeMatches, - hasActiveCreateBlock: params.hasActiveCreateBlock, - retryBusyAgentId: params.retryBusyAgentId, - pendingSetupsByAgentId: params.pendingSetupsByAgentId, - knownAgentIds: params.knownAgentIds, - attemptedAgentIds: params.attemptedAgentIds, - inFlightAgentIds: params.inFlightAgentIds, - }); - if (intent.kind !== "retry") { - return false; - } - params.attemptedAgentIds.add(intent.agentId); - return await params.applyRetry(intent.agentId); -}; - diff --git a/src/features/agents/operations/pendingGuidedSetupRetryOperation.ts b/src/features/agents/operations/pendingGuidedSetupRetryOperation.ts deleted file mode 100644 index fc7631f..0000000 --- a/src/features/agents/operations/pendingGuidedSetupRetryOperation.ts +++ /dev/null @@ -1,61 +0,0 @@ -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { - beginPendingGuidedSetupRetry, - endPendingGuidedSetupRetry, -} from "@/features/agents/creation/pendingSetupRetry"; -import { - runPendingSetupRetryLifecycle, - type PendingSetupRetrySource, -} from "@/features/agents/operations/pendingSetupLifecycleWorkflow"; - -type SetState = (next: T | ((current: T) => T)) => void; - -export const applyPendingGuidedSetupRetryViaStudio = async (params: { - agentId: string; - source: PendingSetupRetrySource; - retryBusyAgentId: string | null; - inFlightAgentIds: Set; - pendingSetupsByAgentId: Record; - setRetryBusyAgentId: SetState; - executeRetry: (agentId: string) => Promise<{ applied: boolean }>; - isDisconnectLikeError: (error: unknown) => boolean; - resolveAgentName: (agentId: string) => string; - onApplied: () => Promise | void; - onError: (message: string) => void; -}): Promise => { - const resolvedAgentId = params.agentId.trim(); - if (!resolvedAgentId) return false; - if (params.retryBusyAgentId && params.retryBusyAgentId !== resolvedAgentId) { - return false; - } - if (!beginPendingGuidedSetupRetry(params.inFlightAgentIds, resolvedAgentId)) { - return false; - } - const pendingSetup = params.pendingSetupsByAgentId[resolvedAgentId] ?? null; - if (!pendingSetup) { - endPendingGuidedSetupRetry(params.inFlightAgentIds, resolvedAgentId); - return false; - } - params.setRetryBusyAgentId(resolvedAgentId); - try { - return await runPendingSetupRetryLifecycle( - { - agentId: resolvedAgentId, - source: params.source, - }, - { - executeRetry: params.executeRetry, - isDisconnectLikeError: params.isDisconnectLikeError, - resolveAgentName: params.resolveAgentName, - onApplied: params.onApplied, - onError: params.onError, - } - ); - } finally { - endPendingGuidedSetupRetry(params.inFlightAgentIds, resolvedAgentId); - params.setRetryBusyAgentId((current) => - current === resolvedAgentId ? null : current - ); - } -}; - diff --git a/src/features/agents/operations/pendingSetupLifecycleWorkflow.ts b/src/features/agents/operations/pendingSetupLifecycleWorkflow.ts deleted file mode 100644 index 5127d38..0000000 --- a/src/features/agents/operations/pendingSetupLifecycleWorkflow.ts +++ /dev/null @@ -1,84 +0,0 @@ -import type { GatewayStatus } from "@/features/agents/operations/gatewayRestartPolicy"; - -export type PendingSetupRetrySource = "auto" | "manual"; - -export type PendingSetupAutoRetryGateInput = { - status: GatewayStatus; - agentsLoadedOnce: boolean; - loadedScopeMatches: boolean; - hasActiveCreateBlock: boolean; - retryBusyAgentId: string | null; -}; - -export type PendingSetupRetryRunnerDeps = { - executeRetry: (agentId: string) => Promise<{ applied: boolean }>; - isDisconnectLikeError: (error: unknown) => boolean; - resolveAgentName: (agentId: string) => string; - onApplied: () => Promise | void; - onError: (message: string) => void; -}; - -const FALLBACK_RETRY_ERROR_MESSAGE = "Retrying guided setup failed."; - -export const shouldAttemptPendingSetupAutoRetry = ( - input: PendingSetupAutoRetryGateInput -): boolean => { - if (input.status !== "connected") return false; - if (!input.agentsLoadedOnce) return false; - if (!input.loadedScopeMatches) return false; - if (input.hasActiveCreateBlock) return false; - if (Boolean(input.retryBusyAgentId)) return false; - return true; -}; - -export const shouldSuppressPendingSetupRetryError = (params: { - source: PendingSetupRetrySource; - disconnectLike: boolean; -}): boolean => { - return params.source === "auto" && params.disconnectLike; -}; - -export const buildPendingSetupRetryErrorMessage = (params: { - source: PendingSetupRetrySource; - agentName: string; - errorMessage: string; -}): string => { - const resolvedName = params.agentName.trim() || "unknown agent"; - const resolvedError = params.errorMessage.trim() || FALLBACK_RETRY_ERROR_MESSAGE; - if (params.source === "manual") { - return `Guided setup retry failed for "${resolvedName}". ${resolvedError}`; - } - return `Agent "${resolvedName}" was created, but guided setup is still pending. Retry or discard setup from chat. ${resolvedError}`; -}; - -export const runPendingSetupRetryLifecycle = async ( - params: { agentId: string; source: PendingSetupRetrySource }, - deps: PendingSetupRetryRunnerDeps -): Promise => { - const resolvedAgentId = params.agentId.trim(); - if (!resolvedAgentId) { - return false; - } - try { - const result = await deps.executeRetry(resolvedAgentId); - if (!result.applied) { - return false; - } - await deps.onApplied(); - return true; - } catch (error) { - const disconnectLike = deps.isDisconnectLikeError(error); - if (shouldSuppressPendingSetupRetryError({ source: params.source, disconnectLike })) { - return false; - } - const errorMessage = error instanceof Error ? error.message : FALLBACK_RETRY_ERROR_MESSAGE; - deps.onError( - buildPendingSetupRetryErrorMessage({ - source: params.source, - agentName: deps.resolveAgentName(resolvedAgentId), - errorMessage, - }) - ); - return false; - } -}; diff --git a/tests/unit/agentCreateModal.test.ts b/tests/unit/agentCreateModal.test.ts index 0c1897c..96afb83 100644 --- a/tests/unit/agentCreateModal.test.ts +++ b/tests/unit/agentCreateModal.test.ts @@ -4,6 +4,7 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import { AgentCreateModal } from "@/features/agents/components/AgentCreateModal"; const openModal = (overrides?: { + busy?: boolean; onClose?: () => void; onSubmit?: (payload: unknown) => void; }) => { @@ -13,6 +14,7 @@ const openModal = (overrides?: { createElement(AgentCreateModal, { open: true, suggestedName: "New Agent", + busy: overrides?.busy, onClose, onSubmit, }) @@ -25,181 +27,69 @@ describe("AgentCreateModal", () => { cleanup(); }); - it("submits guided payload through preset-bundle flow", () => { + it("submits simple payload with name and avatar seed", () => { const onSubmit = vi.fn(); openModal({ onSubmit }); - fireEvent.click(screen.getByRole("button", { name: "Product role" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - fireEvent.click(screen.getByRole("button", { name: "Collaborative autonomy profile" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - fireEvent.change(screen.getByLabelText("Agent name"), { - target: { value: "PR Agent" }, + target: { value: "Execution Operator" }, }); - fireEvent.click(screen.getByRole("button", { name: "Launch agent" })); expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ - mode: "guided", - name: "PR Agent", + name: "Execution Operator", avatarSeed: expect.any(String), - draft: expect.objectContaining({ - starterKit: "engineer", - controlLevel: "balanced", - }), }) ); }); - it("renders outcome-centric domain tiles", () => { - openModal(); - - expect(screen.getByText("What does this agent fully own?")).toBeInTheDocument(); - expect(screen.getByText("Product")).toBeInTheDocument(); - expect(screen.getByText("Owns shipping velocity and product quality.")).toBeInTheDocument(); - expect(screen.getByText("Growth")).toBeInTheDocument(); - expect(screen.getByText("Owns acquisition and conversion performance.")).toBeInTheDocument(); - expect(screen.getByText("Revenue")).toBeInTheDocument(); - expect(screen.getByText("Owns monetization and pricing performance.")).toBeInTheDocument(); - expect(screen.getByText("Execution")).toBeInTheDocument(); - expect(screen.getByText("Systems")).toBeInTheDocument(); - expect(screen.getByText("Strategy")).toBeInTheDocument(); - expect(screen.getByText("Owns prioritization and capital allocation.")).toBeInTheDocument(); - expect(screen.getByText("This agent will be accountable for:")).toBeInTheDocument(); - expect(screen.queryByText(/^Includes:/)).not.toBeInTheDocument(); - expect( - screen.queryByText("Think in terms of roles, not tasks. What responsibility do you want to delegate?") - ).not.toBeInTheDocument(); - expect(screen.queryByText("Knowledge")).not.toBeInTheDocument(); - expect(screen.queryByText("Builder")).not.toBeInTheDocument(); - expect(screen.queryByText("Operations")).not.toBeInTheDocument(); - expect(screen.queryByText("Baseline")).not.toBeInTheDocument(); - expect(screen.queryByText("Command: On")).not.toBeInTheDocument(); - expect(screen.queryByText("Web access: Off")).not.toBeInTheDocument(); - expect(screen.queryByText("File tools: On")).not.toBeInTheDocument(); - }); - - it("updates accountability preview when a different domain is selected", () => { - openModal(); - - fireEvent.click(screen.getByRole("button", { name: "Revenue role" })); - - expect(screen.getByText("Monitoring revenue performance")).toBeInTheDocument(); - expect(screen.getByText("Identifying root causes of decline")).toBeInTheDocument(); - expect(screen.getByText("Running pricing and offer experiments")).toBeInTheDocument(); - expect(screen.getByText("Reporting progress autonomously")).toBeInTheDocument(); - }); - - it("keeps preset cards free of sandbox jargon and risk labels", () => { - openModal(); - - expect( - screen.queryByText("Sandbox mode non-main does not sandbox the agent main session.") - ).not.toBeInTheDocument(); - expect(screen.queryByText("Risk: Moderate")).not.toBeInTheDocument(); - }); - - it("supports autonomy profiles and optional fine-tune overrides", () => { - openModal(); - - fireEvent.click(screen.getByRole("button", { name: "Product role" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - - fireEvent.click(screen.getByRole("button", { name: "Conservative autonomy profile" })); - fireEvent.click(screen.getByRole("button", { name: "Show fine-tune capabilities" })); - fireEvent.click(screen.getByRole("button", { name: "Web access on" })); - fireEvent.click(screen.getByRole("button", { name: "File changes on" })); - fireEvent.click(screen.getByRole("button", { name: "Command execution auto" })); - - expect(screen.getByText("Can modify your codebase directly.")).toBeInTheDocument(); - expect(screen.getByText("Can operate your system automatically.")).toBeInTheDocument(); - }); - - it("sets sandbox off when command execution is ask first", () => { + it("submits when the form is submitted from keyboard flow", () => { const onSubmit = vi.fn(); openModal({ onSubmit }); - fireEvent.click(screen.getByRole("button", { name: "Product role" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - fireEvent.click(screen.getByRole("button", { name: "Show fine-tune capabilities" })); - fireEvent.click(screen.getByRole("button", { name: "Command execution ask first" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); fireEvent.change(screen.getByLabelText("Agent name"), { - target: { value: "Ask First Agent" }, + target: { value: "Keyboard Agent" }, }); - fireEvent.click(screen.getByRole("button", { name: "Launch agent" })); + fireEvent.submit(screen.getByTestId("agent-create-modal")); expect(onSubmit).toHaveBeenCalledWith( expect.objectContaining({ - draft: expect.objectContaining({ - controls: expect.objectContaining({ - allowExec: true, - execAutonomy: "ask-first", - sandboxMode: "off", - }), - }), + name: "Keyboard Agent", }) ); }); - it("shows avatar controls on customize step and removes task/instruction inputs", () => { + it("renders one-step create form without guided wizard copy", () => { openModal(); - fireEvent.click(screen.getByRole("button", { name: "Strategy role" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - - expect(screen.getByText("Activation begins immediately.")).toBeInTheDocument(); - expect(screen.getByText("This agent will:")).toBeInTheDocument(); - expect(screen.getByText("On launch it will:")).toBeInTheDocument(); - expect(screen.getByText("Authority:")).toBeInTheDocument(); + expect(screen.getByText("Launch Agent")).toBeInTheDocument(); + expect(screen.getByLabelText("Agent name")).toBeInTheDocument(); expect(screen.getByText("Choose avatar")).toBeInTheDocument(); expect(screen.getByRole("button", { name: "Shuffle avatar selection" })).toBeInTheDocument(); - expect(screen.getByText("Name reflects its role. You can change it later.")).toBeInTheDocument(); - expect(screen.queryByLabelText("First task")).not.toBeInTheDocument(); - expect(screen.queryByLabelText("Custom instructions (optional)")).not.toBeInTheDocument(); + expect(screen.queryByText("Define Ownership")).not.toBeInTheDocument(); + expect(screen.queryByText("Set Authority Level")).not.toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Next" })).not.toBeInTheDocument(); }); - it("supports revenue as a first-class preset", () => { + it("disables launch when the name is blank", () => { const onSubmit = vi.fn(); openModal({ onSubmit }); - fireEvent.click(screen.getByRole("button", { name: "Revenue role" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - fireEvent.change(screen.getByLabelText("Agent name"), { - target: { value: "Custom Owner Agent" }, + target: { value: " " }, }); - - fireEvent.click(screen.getByRole("button", { name: "Launch agent" })); - - expect(onSubmit).toHaveBeenCalledWith( - expect.objectContaining({ - draft: expect.objectContaining({ - starterKit: "marketer", - }), - }) - ); + const launchButton = screen.getByRole("button", { name: "Launch agent" }); + expect(launchButton).toBeDisabled(); + fireEvent.click(launchButton); + expect(onSubmit).not.toHaveBeenCalled(); }); - it("keeps step three focused without advanced configuration controls", () => { - openModal(); + it("shows launching state while busy", () => { + openModal({ busy: true }); - fireEvent.click(screen.getByRole("button", { name: "Product role" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - fireEvent.click(screen.getByRole("button", { name: "Next" })); - - expect(screen.queryByRole("button", { name: "Show advanced controls" })).not.toBeInTheDocument(); - expect(screen.queryByText("Tool profile")).not.toBeInTheDocument(); - expect(screen.queryByText("Sandbox mode")).not.toBeInTheDocument(); - expect(screen.queryByText("Approval mode")).not.toBeInTheDocument(); - expect( - screen.queryByText("Additional tool allowlist entries (comma or newline separated)") - ).not.toBeInTheDocument(); - expect(screen.queryByLabelText("First task")).not.toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Launching..." })).toBeDisabled(); + expect(screen.getByRole("button", { name: "Close" })).toBeDisabled(); }); it("calls onClose when close is pressed", () => { @@ -209,4 +99,32 @@ describe("AgentCreateModal", () => { fireEvent.click(screen.getByRole("button", { name: "Close" })); expect(onClose).toHaveBeenCalledTimes(1); }); + + it("does not reset typed name when suggestedName changes while open", () => { + const onClose = vi.fn(); + const onSubmit = vi.fn(); + const view = render( + createElement(AgentCreateModal, { + open: true, + suggestedName: "New Agent", + onClose, + onSubmit, + }) + ); + + fireEvent.change(screen.getByLabelText("Agent name"), { + target: { value: "My Draft Name" }, + }); + + view.rerender( + createElement(AgentCreateModal, { + open: true, + suggestedName: "New Agent 2", + onClose, + onSubmit, + }) + ); + + expect(screen.getByLabelText("Agent name")).toHaveValue("My Draft Name"); + }); }); diff --git a/tests/unit/agentCreationCompiler.test.ts b/tests/unit/agentCreationCompiler.test.ts deleted file mode 100644 index 9a4fc9c..0000000 --- a/tests/unit/agentCreationCompiler.test.ts +++ /dev/null @@ -1,224 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - compileGuidedAgentCreation, - createDefaultGuidedDraft, - deriveGuidedPresetCapabilitySummary, - resolveGuidedControlsForPreset, - resolveGuidedDraftFromPresetBundle, -} from "@/features/agents/creation/compiler"; -import type { GuidedAgentCreationDraft } from "@/features/agents/creation/types"; - -const createDraft = (): GuidedAgentCreationDraft => { - const draft = createDefaultGuidedDraft(); - return { - ...draft, - starterKit: "engineer", - controlLevel: "balanced", - customInstructions: "Prefer minimal, test-backed diffs.", - userProfile: "Product engineer who prefers concise summaries.", - toolNotes: "Use git history and markdown formatting conventions.", - memoryNotes: "Remember recurring formatting preferences.", - heartbeatEnabled: false, - heartbeatChecklist: ["Check stale release notes.", "Confirm source links.", "Report only blockers."], - }; -}; - -describe("compileGuidedAgentCreation", () => { - it("compiles default starter draft without legacy outcome-form errors", () => { - const result = compileGuidedAgentCreation({ - name: "Agent", - draft: createDefaultGuidedDraft(), - }); - expect(result.validation.errors).toEqual([]); - }); - - it("maps researcher + conservative to safe defaults", () => { - const draft = createDraft(); - draft.starterKit = "researcher"; - draft.controlLevel = "conservative"; - draft.controls = resolveGuidedControlsForPreset({ - starterKit: draft.starterKit, - controlLevel: draft.controlLevel, - }); - const result = compileGuidedAgentCreation({ - name: "Research Agent", - draft, - }); - - expect(result.validation.errors).toEqual([]); - expect(result.agentOverrides.sandbox).toEqual({ - mode: "off", - workspaceAccess: "rw", - }); - expect(result.agentOverrides.tools?.profile).toBe("minimal"); - expect(result.agentOverrides.tools?.allow).toBeUndefined(); - expect(result.agentOverrides.tools?.alsoAllow).toContain("group:web"); - expect(result.agentOverrides.tools?.alsoAllow).toContain("group:fs"); - expect(result.agentOverrides.tools?.deny).toContain("group:runtime"); - expect(result.files["AGENTS.md"]).toBeUndefined(); - expect(result.files["IDENTITY.md"]).toContain("Role: Research analyst"); - expect(result.files["IDENTITY.md"]).toContain("Creature: Analyst Cartographer"); - expect(result.files["SOUL.md"]).toContain("## Core Truths"); - expect(result.files["SOUL.md"]).toContain("Evidence beats intuition when stakes are non-trivial."); - expect(result.execApprovals).toBeNull(); - }); - - it("maps engineer + balanced to coding defaults with runtime enabled", () => { - const draft = createDraft(); - draft.starterKit = "engineer"; - draft.controlLevel = "balanced"; - draft.controls = resolveGuidedControlsForPreset({ - starterKit: draft.starterKit, - controlLevel: draft.controlLevel, - }); - const result = compileGuidedAgentCreation({ - name: "Engineer Agent", - draft, - }); - - expect(result.validation.errors).toEqual([]); - expect(Object.keys(result.files).sort()).toEqual(["IDENTITY.md", "SOUL.md"]); - expect(result.files["AGENTS.md"]).toBeUndefined(); - expect(result.files["IDENTITY.md"]).toContain("Role: Software engineer"); - expect(result.files["IDENTITY.md"]).toContain("Creature: Pragmatic Builder"); - expect(result.files["SOUL.md"]).toContain("## Core Truths"); - expect(result.files["SOUL.md"]).toContain("Small scoped changes reduce operational risk."); - expect(result.agentOverrides.tools?.profile).toBe("coding"); - expect(result.agentOverrides.tools?.alsoAllow).toContain("group:fs"); - expect(result.agentOverrides.tools?.alsoAllow).toContain("group:runtime"); - expect(result.agentOverrides.tools?.deny).not.toContain("group:runtime"); - expect(result.execApprovals).toEqual({ - security: "allowlist", - ask: "on-miss", - allowlist: [], - }); - }); - - it("maps marketer + conservative to messaging defaults", () => { - const draft = createDraft(); - draft.starterKit = "marketer"; - draft.controlLevel = "conservative"; - draft.controls = resolveGuidedControlsForPreset({ - starterKit: draft.starterKit, - controlLevel: draft.controlLevel, - }); - const result = compileGuidedAgentCreation({ - name: "Marketing Agent", - draft, - }); - - expect(result.validation.errors).toEqual([]); - expect(result.agentOverrides.tools?.profile).toBe("messaging"); - expect(result.agentOverrides.tools?.alsoAllow).toContain("group:web"); - expect(result.agentOverrides.tools?.alsoAllow).toContain("group:fs"); - expect(result.agentOverrides.tools?.deny).toContain("group:runtime"); - expect(result.files["AGENTS.md"]).toBeUndefined(); - expect(result.files["IDENTITY.md"]).toContain("Role: Marketing operator"); - expect(result.files["IDENTITY.md"]).toContain("Creature: Signal Operator"); - expect(result.files["SOUL.md"]).toContain("Message-market fit beats channel hacks."); - expect(result.execApprovals).toBeNull(); - }); - - it("keeps contradiction validation for manual control overrides", () => { - const draft = createDraft(); - draft.controlLevel = "autopilot"; - draft.controls = resolveGuidedControlsForPreset({ - starterKit: draft.starterKit, - controlLevel: draft.controlLevel, - }); - draft.controls.allowExec = false; - - const result = compileGuidedAgentCreation({ - name: "Broken Agent", - draft, - }); - - expect(result.validation.errors).toContain("Auto exec requires runtime tools to be enabled."); - }); - - it("forces sandbox mode off when compiling guided agent creation", () => { - const draft = createDraft(); - draft.controls.allowExec = true; - draft.controls.execAutonomy = "ask-first"; - draft.controls.sandboxMode = "off"; - - const result = compileGuidedAgentCreation({ - name: "Sandbox Normalization Agent", - draft, - }); - - expect(result.validation.errors).toEqual([]); - expect(result.agentOverrides.sandbox).toEqual({ - mode: "off", - workspaceAccess: draft.controls.workspaceAccess, - }); - }); - - it("maps PR Engineer bundle to engineer + autopilot defaults", () => { - const draft = resolveGuidedDraftFromPresetBundle({ - bundle: "pr-engineer", - seed: createDefaultGuidedDraft(), - }); - - expect(draft.starterKit).toBe("engineer"); - expect(draft.controlLevel).toBe("autopilot"); - expect(draft.controls.toolsProfile).toBe("coding"); - expect(draft.controls.allowExec).toBe(true); - expect(draft.controls.sandboxMode).toBe("off"); - expect(draft.controls.workspaceAccess).toBe("rw"); - expect(draft.controls.toolsAllow).toContain("group:web"); - expect(draft.controls.toolsAllow).toContain("group:fs"); - expect(draft.heartbeatEnabled).toBe(false); - }); - - it("maps Autonomous Engineer bundle to engineer + autopilot defaults", () => { - const draft = resolveGuidedDraftFromPresetBundle({ - bundle: "autonomous-engineer", - seed: createDefaultGuidedDraft(), - }); - - expect(draft.starterKit).toBe("engineer"); - expect(draft.controlLevel).toBe("autopilot"); - expect(draft.controls.allowExec).toBe(true); - expect(draft.controls.execAutonomy).toBe("auto"); - expect(draft.controls.fileEditAutonomy).toBe("auto-edit"); - expect(draft.controls.sandboxMode).toBe("off"); - expect(draft.controls.workspaceAccess).toBe("rw"); - }); - - it("derives capability chips from controls", () => { - const draft = resolveGuidedDraftFromPresetBundle({ - bundle: "pr-engineer", - seed: createDefaultGuidedDraft(), - }); - const capability = deriveGuidedPresetCapabilitySummary({ - controls: draft.controls, - }); - - expect(capability.chips).toEqual( - expect.arrayContaining([ - expect.objectContaining({ id: "command", label: "Command", enabled: true, value: "On" }), - expect.objectContaining({ id: "web", label: "Web access", enabled: true, value: "On" }), - expect.objectContaining({ - id: "files", - label: "File tools", - enabled: true, - value: "On", - }), - ]) - ); - }); - - it("does not include risk or caveat metadata in capability chips", () => { - const draft = resolveGuidedDraftFromPresetBundle({ - bundle: "research-analyst", - seed: createDefaultGuidedDraft(), - }); - const capability = deriveGuidedPresetCapabilitySummary({ - controls: draft.controls, - }); - - expect("risk" in capability).toBe(false); - expect("caveats" in capability).toBe(false); - }); -}); diff --git a/tests/unit/agentMutationLifecycleController.integration.test.ts b/tests/unit/agentMutationLifecycleController.integration.test.ts index 1019990..573ae09 100644 --- a/tests/unit/agentMutationLifecycleController.integration.test.ts +++ b/tests/unit/agentMutationLifecycleController.integration.test.ts @@ -4,55 +4,29 @@ import { buildMutationSideEffectCommands, buildQueuedMutationBlock, resolveMutationStartGuard, - resolvePendingSetupAutoRetryIntent, } from "@/features/agents/operations/agentMutationLifecycleController"; -import { - resolveGuidedCreateCompletion, - runGuidedCreateWorkflow, -} from "@/features/agents/operations/guidedCreateWorkflow"; import { resolveConfigMutationStatusLine, runConfigMutationWorkflow, } from "@/features/agents/operations/configMutationWorkflow"; -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; - -const createSetup = (): AgentGuidedSetup => ({ - agentOverrides: { - sandbox: { mode: "non-main", workspaceAccess: "ro" }, - tools: { profile: "coding", alsoAllow: ["group:runtime"], deny: ["group:web"] }, - }, - files: { - "AGENTS.md": "# Mission", - }, - execApprovals: { - security: "allowlist", - ask: "always", - allowlist: [{ pattern: "/usr/bin/git" }], - }, -}); describe("agentMutationLifecycleController integration", () => { - it("page create handler maps controller decisions to guided create flow side effects", async () => { - const setup = createSetup(); - const guardDenied = resolveMutationStartGuard({ + it("page create handler uses shared start guard and queued block shape", () => { + const denied = resolveMutationStartGuard({ status: "disconnected", hasCreateBlock: false, hasRenameBlock: false, hasDeleteBlock: false, }); + expect(denied).toEqual({ kind: "deny", reason: "not-connected" }); - expect(guardDenied).toEqual({ - kind: "deny", - reason: "not-connected", - }); - - const guardAllowed = resolveMutationStartGuard({ + const allowed = resolveMutationStartGuard({ status: "connected", hasCreateBlock: false, hasRenameBlock: false, hasDeleteBlock: false, }); - expect(guardAllowed).toEqual({ kind: "allow" }); + expect(allowed).toEqual({ kind: "allow" }); const queued = buildQueuedMutationBlock({ kind: "create-agent", @@ -60,38 +34,14 @@ describe("agentMutationLifecycleController integration", () => { agentName: "Agent One", startedAt: 42, }); - expect(queued.phase).toBe("queued"); - - const pendingByAgentId: Record = {}; - const result = await runGuidedCreateWorkflow( - { - name: "Agent One", - setup, - isLocalGateway: true, - }, - { - createAgent: async () => ({ id: "agent-1" }), - applySetup: async () => { - throw new Error("setup failed"); - }, - upsertPending: (agentId, nextSetup) => { - pendingByAgentId[agentId] = nextSetup; - }, - removePending: (agentId) => { - delete pendingByAgentId[agentId]; - }, - } - ); - - const completion = resolveGuidedCreateCompletion({ + expect(queued).toEqual({ + kind: "create-agent", + agentId: "", agentName: "Agent One", - result, + phase: "queued", + startedAt: 42, + sawDisconnect: false, }); - - expect(result.setupStatus).toBe("pending"); - expect(pendingByAgentId["agent-1"]).toEqual(setup); - expect(completion.shouldReloadAgents).toBe(true); - expect(completion.pendingErrorMessage).toContain("guided setup is pending"); }); it("page rename and delete handlers share lifecycle guard plus post-run transitions", async () => { @@ -148,58 +98,6 @@ describe("agentMutationLifecycleController integration", () => { expect(executeMutation).toHaveBeenCalledTimes(2); }); - it("page pending setup auto-retry effect only runs for controller retry intents", () => { - const applyPendingCreateSetupForAgentId = vi.fn(); - - const retryIntent = resolvePendingSetupAutoRetryIntent({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - pendingSetupsByAgentId: { - "agent-2": {}, - }, - knownAgentIds: new Set(["agent-2"]), - attemptedAgentIds: new Set(), - inFlightAgentIds: new Set(), - }); - - if (retryIntent.kind === "retry") { - applyPendingCreateSetupForAgentId({ - agentId: retryIntent.agentId, - source: "auto", - }); - } - expect(applyPendingCreateSetupForAgentId).toHaveBeenCalledTimes(1); - - const skipIntent = resolvePendingSetupAutoRetryIntent({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - pendingSetupsByAgentId: { - "agent-2": {}, - }, - knownAgentIds: new Set(["agent-2"]), - attemptedAgentIds: new Set(["agent-2"]), - inFlightAgentIds: new Set(), - }); - - if (skipIntent.kind === "retry") { - applyPendingCreateSetupForAgentId({ - agentId: skipIntent.agentId, - source: "auto", - }); - } - expect(skipIntent).toEqual({ - kind: "skip", - reason: "no-eligible-agent", - }); - expect(applyPendingCreateSetupForAgentId).toHaveBeenCalledTimes(1); - }); - it("uses typed mutation commands for lifecycle side effects instead of inline branching", async () => { const commandLog: string[] = []; const runCommands = async ( diff --git a/tests/unit/agentMutationLifecycleController.test.ts b/tests/unit/agentMutationLifecycleController.test.ts index 3b9cb75..61c2f4d 100644 --- a/tests/unit/agentMutationLifecycleController.test.ts +++ b/tests/unit/agentMutationLifecycleController.test.ts @@ -7,7 +7,6 @@ import { resolveMutationPostRunIntent, resolveMutationStartGuard, resolveMutationTimeoutIntent, - resolvePendingSetupAutoRetryIntent, } from "@/features/agents/operations/agentMutationLifecycleController"; describe("agentMutationLifecycleController", () => { @@ -110,47 +109,6 @@ describe("agentMutationLifecycleController", () => { ]); }); - it("selects pending setup auto-retry target only when all gates pass", () => { - expect( - resolvePendingSetupAutoRetryIntent({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - pendingSetupsByAgentId: { - "agent-b": {}, - "agent-a": {}, - }, - knownAgentIds: new Set(["agent-a", "agent-b"]), - attemptedAgentIds: new Set(["agent-a"]), - inFlightAgentIds: new Set(), - }) - ).toEqual({ - kind: "retry", - agentId: "agent-b", - }); - - expect( - resolvePendingSetupAutoRetryIntent({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - pendingSetupsByAgentId: { - "agent-a": {}, - }, - knownAgentIds: new Set(["agent-a"]), - attemptedAgentIds: new Set(["agent-a"]), - inFlightAgentIds: new Set(), - }) - ).toEqual({ - kind: "skip", - reason: "no-eligible-agent", - }); - }); - it("returns timeout intent when mutation block exceeds max wait", () => { expect( resolveMutationTimeoutIntent({ diff --git a/tests/unit/createAgentMutationLifecycleOperation.test.ts b/tests/unit/createAgentMutationLifecycleOperation.test.ts index ec43675..f021a1b 100644 --- a/tests/unit/createAgentMutationLifecycleOperation.test.ts +++ b/tests/unit/createAgentMutationLifecycleOperation.test.ts @@ -1,23 +1,16 @@ import { describe, expect, it, vi } from "vitest"; -import { createDefaultGuidedDraft } from "@/features/agents/creation/compiler"; import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types"; -import type { - CreateAgentMutationLifecycleDeps, -} from "@/features/agents/operations/createAgentMutationLifecycleOperation"; +import type { CreateAgentMutationLifecycleDeps } from "@/features/agents/operations/createAgentMutationLifecycleOperation"; import { isCreateBlockTimedOut, runCreateAgentMutationLifecycle, - runPendingCreateSetupRetryLifecycle, } from "@/features/agents/operations/createAgentMutationLifecycleOperation"; -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; const createPayload = ( overrides: Partial = {} ): AgentCreateModalSubmitPayload => ({ - mode: "guided", name: "Agent One", - draft: createDefaultGuidedDraft(), avatarSeed: "seed-1", ...overrides, }); @@ -29,14 +22,9 @@ const createDeps = ( await run(); }, createAgent: async () => ({ id: "agent-1" }), - applySetup: async () => undefined, - upsertPending: () => undefined, - removePending: () => undefined, setQueuedBlock: () => undefined, setCreatingBlock: () => undefined, - setApplyingSetupBlock: () => undefined, onCompletion: async () => undefined, - setCreateAgentModalOpen: () => undefined, setCreateAgentModalError: () => undefined, setCreateAgentBusy: () => undefined, clearCreateBlock: () => undefined, @@ -57,7 +45,6 @@ describe("createAgentMutationLifecycleOperation", () => { hasRenameBlock: false, hasDeleteBlock: false, createAgentBusy: false, - isLocalGateway: true, }, createDeps({ setCreateAgentModalError, @@ -70,22 +57,18 @@ describe("createAgentMutationLifecycleOperation", () => { expect(enqueueConfigMutation).not.toHaveBeenCalled(); }); - it("fails fast on compile validation error and does not enqueue mutation", async () => { + it("fails fast when the submitted name is empty", async () => { const setCreateAgentModalError = vi.fn(); const enqueueConfigMutation = vi.fn(async () => undefined); - const invalidDraft = createDefaultGuidedDraft(); - invalidDraft.controls.execAutonomy = "auto"; - invalidDraft.controls.allowExec = false; const result = await runCreateAgentMutationLifecycle( { - payload: createPayload({ draft: invalidDraft }), + payload: createPayload({ name: " " }), status: "connected", hasCreateBlock: false, hasRenameBlock: false, hasDeleteBlock: false, createAgentBusy: false, - isLocalGateway: true, }, createDeps({ setCreateAgentModalError, @@ -94,14 +77,14 @@ describe("createAgentMutationLifecycleOperation", () => { ); expect(result).toBe(false); - expect(setCreateAgentModalError).toHaveBeenCalledWith("Auto exec requires runtime tools to be enabled."); + expect(setCreateAgentModalError).toHaveBeenCalledWith("Agent name is required."); expect(enqueueConfigMutation).not.toHaveBeenCalled(); }); - it("runs successful local create/apply flow and completion commands", async () => { + it("runs create-only lifecycle and completion callback", async () => { const order: string[] = []; - const onCompletion = vi.fn(async (completion: { pendingErrorMessage: string | null }) => { - order.push(`completion:${completion.pendingErrorMessage === null ? "applied" : "pending"}`); + const onCompletion = vi.fn(async (completion: { agentId: string; agentName: string }) => { + order.push(`completion:${completion.agentId}:${completion.agentName}`); }); const result = await runCreateAgentMutationLifecycle( @@ -112,7 +95,6 @@ describe("createAgentMutationLifecycleOperation", () => { hasRenameBlock: false, hasDeleteBlock: false, createAgentBusy: false, - isLocalGateway: true, }, createDeps({ setCreateAgentBusy: (busy) => { @@ -135,18 +117,6 @@ describe("createAgentMutationLifecycleOperation", () => { order.push("createAgent"); return { id: "agent-1" }; }, - setApplyingSetupBlock: () => { - order.push("applying"); - }, - applySetup: async () => { - order.push("applySetup"); - }, - removePending: () => { - order.push("removePending"); - }, - setCreateAgentModalOpen: (open) => { - order.push(`modalOpen:${open ? "true" : "false"}`); - }, onCompletion, }) ); @@ -159,20 +129,16 @@ describe("createAgentMutationLifecycleOperation", () => { "enqueue", "creating", "createAgent", - "modalOpen:false", - "applying", - "applySetup", - "removePending", - "completion:applied", + "completion:agent-1:Agent One", "busy:off", ]); expect(onCompletion).toHaveBeenCalledTimes(1); }); - it("keeps create successful but reports pending completion when setup apply fails", async () => { - const upsertPending = vi.fn(); - const removePending = vi.fn(); - const onCompletion = vi.fn(); + it("surfaces create errors and clears create block", async () => { + const clearCreateBlock = vi.fn(); + const setCreateAgentModalError = vi.fn(); + const onError = vi.fn(); const result = await runCreateAgentMutationLifecycle( { @@ -182,78 +148,21 @@ describe("createAgentMutationLifecycleOperation", () => { hasRenameBlock: false, hasDeleteBlock: false, createAgentBusy: false, - isLocalGateway: true, }, createDeps({ - applySetup: async () => { - throw new Error("setup exploded"); + createAgent: async () => { + throw new Error("create exploded"); }, - upsertPending, - removePending, - onCompletion, + clearCreateBlock, + setCreateAgentModalError, + onError, }) ); - expect(result).toBe(true); - expect(upsertPending).toHaveBeenCalledTimes(1); - expect(removePending).not.toHaveBeenCalled(); - expect(onCompletion).toHaveBeenCalledWith({ - shouldReloadAgents: true, - shouldCloseCreateModal: true, - pendingErrorMessage: - 'Agent "Agent One" was created, but guided setup is pending. Retry or discard setup from chat. setup exploded', - }); - }); - - it("handles manual pending setup retry success", async () => { - const pendingSetup = {} as AgentGuidedSetup; - const onApplied = vi.fn(); - const removePending = vi.fn(); - const onError = vi.fn(); - - const result = await runPendingCreateSetupRetryLifecycle({ - agentId: "agent-1", - source: "manual", - retryBusyAgentId: null, - inFlightAgentIds: new Set(), - pendingSetupsByAgentId: { "agent-1": pendingSetup }, - setRetryBusyAgentId: () => undefined, - applyPendingSetup: async () => ({ applied: true }), - removePending, - isDisconnectLikeError: () => false, - resolveAgentName: () => "Agent One", - onApplied, - onError, - }); - - expect(result).toBe(true); - expect(removePending).toHaveBeenCalledWith("agent-1"); - expect(onApplied).toHaveBeenCalledTimes(1); - expect(onError).not.toHaveBeenCalled(); - }); - - it("surfaces manual pending setup retry failures", async () => { - const onError = vi.fn(); - - const result = await runPendingCreateSetupRetryLifecycle({ - agentId: "agent-1", - source: "manual", - retryBusyAgentId: null, - inFlightAgentIds: new Set(), - pendingSetupsByAgentId: { "agent-1": {} as AgentGuidedSetup }, - setRetryBusyAgentId: () => undefined, - applyPendingSetup: async () => { - throw new Error("retry exploded"); - }, - removePending: () => undefined, - isDisconnectLikeError: () => false, - resolveAgentName: () => "Agent One", - onApplied: () => undefined, - onError, - }); - expect(result).toBe(false); - expect(onError).toHaveBeenCalledWith('Guided setup retry failed for "Agent One". retry exploded'); + expect(clearCreateBlock).toHaveBeenCalledTimes(1); + expect(setCreateAgentModalError).toHaveBeenCalledWith("create exploded"); + expect(onError).toHaveBeenCalledWith("create exploded"); }); it("maps create block timeout through shared mutation timeout policy", () => { @@ -268,7 +177,6 @@ describe("createAgentMutationLifecycleOperation", () => { expect( isCreateBlockTimedOut({ block: { - agentId: null, agentName: "Agent One", phase: "queued", startedAt: 0, @@ -281,27 +189,13 @@ describe("createAgentMutationLifecycleOperation", () => { expect( isCreateBlockTimedOut({ block: { - agentId: "agent-1", agentName: "Agent One", phase: "creating", startedAt: 0, }, - nowMs: 95_000, + nowMs: 100_000, maxWaitMs: 90_000, }) ).toBe(true); - - expect( - isCreateBlockTimedOut({ - block: { - agentId: "agent-1", - agentName: "Agent One", - phase: "applying-setup", - startedAt: 0, - }, - nowMs: 45_000, - maxWaitMs: 90_000, - }) - ).toBe(false); }); }); diff --git a/tests/unit/createAgentOperation.test.ts b/tests/unit/createAgentOperation.test.ts deleted file mode 100644 index 2c4cefa..0000000 --- a/tests/unit/createAgentOperation.test.ts +++ /dev/null @@ -1,269 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import type { GatewayClient } from "@/lib/gateway/GatewayClient"; -import { - compileGuidedAgentCreation, - createDefaultGuidedDraft, - resolveGuidedDraftFromPresetBundle, -} from "@/features/agents/creation/compiler"; -import type { AgentPresetBundle } from "@/features/agents/creation/types"; -import { - applyGuidedAgentSetup, - createAgentWithOptionalSetup, - type AgentGuidedSetup, -} from "@/features/agents/operations/createAgentOperation"; - -const createSetup = (): AgentGuidedSetup => ({ - agentOverrides: { - sandbox: { mode: "non-main", workspaceAccess: "ro" }, - tools: { profile: "coding", alsoAllow: ["group:runtime"], deny: ["group:web"] }, - }, - files: { - "AGENTS.md": "# Mission", - "SOUL.md": "# Tone", - }, - execApprovals: { - security: "allowlist", - ask: "always", - allowlist: [{ pattern: "/usr/bin/git" }], - }, -}); - -const createSetupFromBundle = (bundle: AgentPresetBundle): AgentGuidedSetup => { - const draft = resolveGuidedDraftFromPresetBundle({ - bundle, - seed: createDefaultGuidedDraft(), - }); - const compiled = compileGuidedAgentCreation({ - name: "Bundle Agent", - draft, - }); - expect(compiled.validation.errors).toEqual([]); - return { - agentOverrides: compiled.agentOverrides, - files: compiled.files, - execApprovals: compiled.execApprovals, - }; -}; - -describe("createAgentOperation", () => { - beforeEach(() => { - globalThis.fetch = vi.fn().mockResolvedValue({ - ok: true, - status: 200, - text: vi.fn(async () => JSON.stringify({ keys: [] })), - }) as unknown as typeof fetch; - }); - - afterEach(() => { - vi.restoreAllMocks(); - }); - - it("applies guided setup for local gateway creation", async () => { - const setup = createSetup(); - const client = { - call: vi.fn(async (method: string, params?: unknown) => { - if (method === "config.get") { - return { - exists: true, - hash: "cfg-1", - path: "/Users/test/.openclaw/openclaw.json", - config: { agents: { list: [] } }, - }; - } - if (method === "agents.create") { - return { ok: true, agentId: "agent-1", name: "Agent 1" }; - } - if (method === "config.set") { - const raw = (params as { raw: string }).raw; - const parsed = JSON.parse(raw) as { - agents?: { list?: Array<{ id: string; sandbox?: unknown; tools?: unknown }> }; - }; - const entry = parsed.agents?.list?.find((item) => item.id === "agent-1"); - expect(entry?.sandbox).toEqual({ mode: "non-main", workspaceAccess: "ro" }); - expect(entry?.tools).toEqual({ - profile: "coding", - alsoAllow: ["group:runtime"], - deny: ["group:web"], - }); - return { ok: true }; - } - if (method === "agents.files.set") { - return { ok: true }; - } - if (method === "exec.approvals.get") { - return { - exists: true, - hash: "ap-1", - file: { - version: 1, - agents: {}, - }, - }; - } - if (method === "exec.approvals.set") { - const payload = params as { - file?: { - agents?: Record }>; - }; - }; - expect(payload.file?.agents?.["agent-1"]).toEqual({ - security: "allowlist", - ask: "always", - allowlist: [{ pattern: "/usr/bin/git" }], - }); - return { ok: true }; - } - throw new Error(`unexpected method ${method}`); - }), - } as unknown as GatewayClient; - - const result = await createAgentWithOptionalSetup({ - client, - name: "Agent 1", - setup, - isLocalGateway: true, - }); - - expect(result).toEqual({ - agentId: "agent-1", - setupApplied: true, - awaitingRestart: false, - }); - }); - - it("defers setup for remote gateways", async () => { - const setup = createSetup(); - const client = { - call: vi.fn(async (method: string) => { - if (method === "config.get") { - return { - exists: true, - hash: "cfg-1", - path: "/Users/test/.openclaw/openclaw.json", - config: { agents: { list: [] } }, - }; - } - if (method === "agents.create") { - return { ok: true, agentId: "agent-2", name: "Agent 2" }; - } - throw new Error(`unexpected method ${method}`); - }), - } as unknown as GatewayClient; - - const result = await createAgentWithOptionalSetup({ - client, - name: "Agent 2", - setup, - isLocalGateway: false, - }); - - expect(result).toEqual({ - agentId: "agent-2", - setupApplied: false, - awaitingRestart: true, - }); - }); - - it("applies setup directly when requested", async () => { - const setup = createSetup(); - const calls: string[] = []; - const client = { - call: vi.fn(async (method: string) => { - calls.push(method); - if (method === "config.get") { - return { - exists: true, - hash: "cfg-2", - config: { agents: { list: [{ id: "agent-3" }] } }, - }; - } - if (method === "config.set") return { ok: true }; - if (method === "agents.files.set") return { ok: true }; - if (method === "exec.approvals.get") { - return { exists: true, hash: "ap-2", file: { version: 1, agents: {} } }; - } - if (method === "exec.approvals.set") return { ok: true }; - throw new Error(`unexpected method ${method}`); - }), - } as unknown as GatewayClient; - - await expect( - applyGuidedAgentSetup({ - client, - agentId: "agent-3", - setup, - }) - ).resolves.toBeUndefined(); - - const lastFilesSet = Math.max( - calls.lastIndexOf("agents.files.set"), - calls.lastIndexOf("exec.approvals.set") - ); - expect(lastFilesSet).toBeGreaterThanOrEqual(0); - expect(calls.lastIndexOf("config.set")).toBeGreaterThan(lastFilesSet); - }); - - it("skips agent override config.set when includeAgentOverrides is false", async () => { - const setup = createSetup(); - const calls: string[] = []; - const client = { - call: vi.fn(async (method: string) => { - calls.push(method); - if (method === "agents.files.set") return { ok: true }; - if (method === "exec.approvals.get") { - return { exists: true, hash: "ap-3", file: { version: 1, agents: {} } }; - } - if (method === "exec.approvals.set") return { ok: true }; - if (method === "config.get" || method === "config.set") { - throw new Error(`unexpected method ${method}`); - } - throw new Error(`unexpected method ${method}`); - }), - } as unknown as GatewayClient; - - await expect( - applyGuidedAgentSetup({ - client, - agentId: "agent-no-overrides", - setup, - includeAgentOverrides: false, - }) - ).resolves.toBeUndefined(); - - expect(calls).not.toContain("config.get"); - expect(calls).not.toContain("config.set"); - }); - - it("applies setup compiled from PR Engineer bundle without creating a new agent", async () => { - const setup = createSetupFromBundle("pr-engineer"); - const calls: string[] = []; - const client = { - call: vi.fn(async (method: string) => { - calls.push(method); - if (method === "config.get") { - return { - exists: true, - hash: "cfg-bundle-1", - config: { agents: { list: [{ id: "agent-bundle" }] } }, - }; - } - if (method === "config.set") return { ok: true }; - if (method === "agents.files.set") return { ok: true }; - if (method === "exec.approvals.get") { - return { exists: true, hash: "ap-bundle-1", file: { version: 1, agents: {} } }; - } - if (method === "exec.approvals.set") return { ok: true }; - throw new Error(`unexpected method ${method}`); - }), - } as unknown as GatewayClient; - - await applyGuidedAgentSetup({ - client, - agentId: "agent-bundle", - setup, - }); - - expect(calls).not.toContain("agents.create"); - expect(calls).toContain("config.set"); - }); -}); diff --git a/tests/unit/guidedCreateWorkflow.integration.test.ts b/tests/unit/guidedCreateWorkflow.integration.test.ts deleted file mode 100644 index e2e3609..0000000 --- a/tests/unit/guidedCreateWorkflow.integration.test.ts +++ /dev/null @@ -1,178 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { - removePendingGuidedSetup, - upsertPendingGuidedSetup, -} from "@/features/agents/creation/recovery"; -import { - beginPendingGuidedSetupRetry, - endPendingGuidedSetupRetry, -} from "@/features/agents/creation/pendingSetupRetry"; -import { - resolveGuidedCreateCompletion, - runGuidedCreateWorkflow, - runGuidedRetryWorkflow, -} from "@/features/agents/operations/guidedCreateWorkflow"; - -const createSetup = (): AgentGuidedSetup => ({ - agentOverrides: { - sandbox: { mode: "non-main", workspaceAccess: "ro" }, - tools: { profile: "coding", alsoAllow: ["group:runtime"], deny: ["group:web"] }, - }, - files: { - "AGENTS.md": "# Mission", - }, - execApprovals: { - security: "allowlist", - ask: "always", - allowlist: [{ pattern: "/usr/bin/git" }], - }, -}); - -describe("guidedCreateWorkflow integration", () => { - it("maps workflow pending outcome to pending setup map update and user error banner", async () => { - const setup = createSetup(); - const pendingByAgentId: Record = {}; - const createAgent = vi.fn(async () => ({ id: "agent-1" })); - const applySetup = vi.fn(async () => { - throw new Error("setup failed"); - }); - - const result = await runGuidedCreateWorkflow( - { - name: "Agent One", - setup, - isLocalGateway: true, - }, - { - createAgent, - applySetup, - upsertPending: (agentId, value) => { - pendingByAgentId[agentId] = value; - }, - removePending: (agentId) => { - delete pendingByAgentId[agentId]; - }, - } - ); - - const completion = resolveGuidedCreateCompletion({ - agentName: "Agent One", - result, - }); - - expect(result.setupStatus).toBe("pending"); - expect(pendingByAgentId).toEqual({ "agent-1": setup }); - expect(completion.pendingErrorMessage).toBe( - 'Agent "Agent One" was created, but guided setup is pending. Retry or discard setup from chat. setup failed' - ); - }); - - it("maps workflow applied outcome to modal close and reload path", async () => { - const setup = createSetup(); - const pendingByAgentId: Record = { "agent-2": setup }; - - const result = await runGuidedCreateWorkflow( - { - name: "Agent Two", - setup, - isLocalGateway: true, - }, - { - createAgent: async () => ({ id: "agent-2" }), - applySetup: async () => undefined, - upsertPending: (agentId, value) => { - pendingByAgentId[agentId] = value; - }, - removePending: (agentId) => { - delete pendingByAgentId[agentId]; - }, - } - ); - - const completion = resolveGuidedCreateCompletion({ - agentName: "Agent Two", - result, - }); - - expect(completion).toEqual({ - shouldReloadAgents: true, - shouldCloseCreateModal: true, - pendingErrorMessage: null, - }); - expect(pendingByAgentId).toEqual({}); - }); - - it("manual retry path uses workflow retry outcome and clears busy state", async () => { - const setup = createSetup(); - const pendingByAgentId: Record = { "agent-3": setup }; - let busyAgentId: string | null = null; - - const manualRetry = async (agentId: string) => { - busyAgentId = agentId; - try { - return await runGuidedRetryWorkflow(agentId, { - applyPendingSetup: async (resolvedAgentId) => { - return { applied: resolvedAgentId === "agent-3" }; - }, - removePending: (resolvedAgentId) => { - delete pendingByAgentId[resolvedAgentId]; - }, - }); - } finally { - busyAgentId = busyAgentId === agentId ? null : busyAgentId; - } - }; - - const result = await manualRetry("agent-3"); - - expect(result).toEqual({ applied: true }); - expect(pendingByAgentId).toEqual({}); - expect(busyAgentId).toBeNull(); - }); - - it("preserves pending setup entry ordering and replacement semantics across retries", async () => { - const setupA1 = createSetup(); - const setupA2 = { - ...createSetup(), - files: { "AGENTS.md": "# Updated mission" }, - }; - const setupB = createSetup(); - let pendingByAgentId: Record = {}; - pendingByAgentId = upsertPendingGuidedSetup(pendingByAgentId, "agent-b", setupB); - pendingByAgentId = upsertPendingGuidedSetup(pendingByAgentId, "agent-a", setupA1); - - await runGuidedRetryWorkflow("agent-a", { - applyPendingSetup: async () => ({ applied: true }), - removePending: (agentId) => { - pendingByAgentId = removePendingGuidedSetup(pendingByAgentId, agentId); - }, - }); - - expect(Object.keys(pendingByAgentId)).toEqual(["agent-b"]); - - pendingByAgentId = upsertPendingGuidedSetup(pendingByAgentId, "agent-a", setupA2); - - expect(Object.keys(pendingByAgentId)).toEqual(["agent-b", "agent-a"]); - expect(pendingByAgentId["agent-a"]).toEqual(setupA2); - }); - - it("does not schedule duplicate retry when in-flight guard is set", async () => { - const inFlightAgentIds = new Set(); - const startedFirst = beginPendingGuidedSetupRetry(inFlightAgentIds, "agent-guarded"); - const startedSecond = beginPendingGuidedSetupRetry(inFlightAgentIds, "agent-guarded"); - - expect(startedFirst).toBe(true); - expect(startedSecond).toBe(false); - - if (startedFirst) { - await runGuidedRetryWorkflow("agent-guarded", { - applyPendingSetup: async () => ({ applied: true }), - removePending: () => undefined, - }); - } - endPendingGuidedSetupRetry(inFlightAgentIds, "agent-guarded"); - - expect(inFlightAgentIds.has("agent-guarded")).toBe(false); - }); -}); diff --git a/tests/unit/guidedCreateWorkflow.test.ts b/tests/unit/guidedCreateWorkflow.test.ts deleted file mode 100644 index fda2ea1..0000000 --- a/tests/unit/guidedCreateWorkflow.test.ts +++ /dev/null @@ -1,143 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { runGuidedCreateWorkflow } from "@/features/agents/operations/guidedCreateWorkflow"; - -const createSetup = (): AgentGuidedSetup => ({ - agentOverrides: { - sandbox: { mode: "non-main", workspaceAccess: "ro" }, - tools: { profile: "coding", alsoAllow: ["group:runtime"], deny: ["group:web"] }, - }, - files: { - "AGENTS.md": "# Mission", - }, - execApprovals: { - security: "allowlist", - ask: "always", - allowlist: [{ pattern: "/usr/bin/git" }], - }, -}); - -describe("guidedCreateWorkflow", () => { - it("returns applied outcome for local gateway when setup succeeds", async () => { - const setup = createSetup(); - const createAgent = vi.fn(async () => ({ id: "agent-1" })); - const applySetup = vi.fn(async () => undefined); - const upsertPending = vi.fn(); - const removePending = vi.fn(); - - const result = await runGuidedCreateWorkflow( - { - name: "Agent One", - setup, - isLocalGateway: true, - }, - { - createAgent, - applySetup, - upsertPending, - removePending, - } - ); - - expect(result).toEqual({ - agentId: "agent-1", - setupStatus: "applied", - setupErrorMessage: null, - }); - expect(upsertPending).not.toHaveBeenCalled(); - expect(removePending).toHaveBeenCalledWith("agent-1"); - }); - - it("returns pending outcome for local gateway when setup fails", async () => { - const setup = createSetup(); - const createAgent = vi.fn(async () => ({ id: "agent-2" })); - const applySetup = vi.fn(async () => { - throw new Error("setup failed"); - }); - const upsertPending = vi.fn(); - const removePending = vi.fn(); - - const result = await runGuidedCreateWorkflow( - { - name: "Agent Two", - setup, - isLocalGateway: true, - }, - { - createAgent, - applySetup, - upsertPending, - removePending, - } - ); - - expect(result).toEqual({ - agentId: "agent-2", - setupStatus: "pending", - setupErrorMessage: "setup failed", - }); - expect(upsertPending).toHaveBeenCalledWith("agent-2", setup); - expect(removePending).not.toHaveBeenCalled(); - }); - - it("returns pending outcome for remote gateway and keeps created agent id", async () => { - const setup = createSetup(); - const createAgent = vi.fn(async () => ({ id: "agent-3" })); - const applySetup = vi.fn(async () => { - throw new Error("network error"); - }); - const upsertPending = vi.fn(); - const removePending = vi.fn(); - - const result = await runGuidedCreateWorkflow( - { - name: "Agent Three", - setup, - isLocalGateway: false, - }, - { - createAgent, - applySetup, - upsertPending, - removePending, - } - ); - - expect(result).toEqual({ - agentId: "agent-3", - setupStatus: "pending", - setupErrorMessage: "network error", - }); - expect(upsertPending).toHaveBeenCalledWith("agent-3", setup); - expect(removePending).not.toHaveBeenCalled(); - }); - - it("rejects empty agent name before any side effect", async () => { - const setup = createSetup(); - const createAgent = vi.fn(async () => ({ id: "agent-x" })); - const applySetup = vi.fn(async () => undefined); - const upsertPending = vi.fn(); - const removePending = vi.fn(); - - await expect( - runGuidedCreateWorkflow( - { - name: " ", - setup, - isLocalGateway: true, - }, - { - createAgent, - applySetup, - upsertPending, - removePending, - } - ) - ).rejects.toThrow("Agent name is required."); - - expect(createAgent).not.toHaveBeenCalled(); - expect(applySetup).not.toHaveBeenCalled(); - expect(upsertPending).not.toHaveBeenCalled(); - expect(removePending).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/guidedSetupRecovery.test.ts b/tests/unit/guidedSetupRecovery.test.ts deleted file mode 100644 index 0347163..0000000 --- a/tests/unit/guidedSetupRecovery.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import type { GatewayClient } from "@/lib/gateway/GatewayClient"; -import { - compileGuidedAgentCreation, - createDefaultGuidedDraft, - resolveGuidedDraftFromPresetBundle, -} from "@/features/agents/creation/compiler"; -import type { AgentPresetBundle } from "@/features/agents/creation/types"; -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { applyPendingGuidedSetupForAgent, upsertPendingGuidedSetup } from "@/features/agents/creation/recovery"; -import { - loadPendingGuidedSetupsFromStorage, - persistPendingGuidedSetupsToStorage, -} from "@/features/agents/creation/pendingSetupStore"; - -class MemoryStorage implements Storage { - private readonly map = new Map(); - - get length() { - return this.map.size; - } - - clear(): void { - this.map.clear(); - } - - getItem(key: string): string | null { - return this.map.get(key) ?? null; - } - - key(index: number): string | null { - return Array.from(this.map.keys())[index] ?? null; - } - - removeItem(key: string): void { - this.map.delete(key); - } - - setItem(key: string, value: string): void { - this.map.set(key, value); - } -} - -const createSetup = (): AgentGuidedSetup => ({ - agentOverrides: { - sandbox: { mode: "non-main", workspaceAccess: "ro" }, - tools: { profile: "coding", alsoAllow: ["group:runtime"], deny: ["group:web"] }, - }, - files: { - "AGENTS.md": "# Mission", - }, - execApprovals: { - security: "allowlist", - ask: "always", - allowlist: [{ pattern: "/usr/bin/git" }], - }, -}); - -const createSetupFromBundle = (bundle: AgentPresetBundle): AgentGuidedSetup => { - const draft = resolveGuidedDraftFromPresetBundle({ - bundle, - seed: createDefaultGuidedDraft(), - }); - const compiled = compileGuidedAgentCreation({ - name: "Recovery Bundle Agent", - draft, - }); - expect(compiled.validation.errors).toEqual([]); - return { - agentOverrides: compiled.agentOverrides, - files: compiled.files, - execApprovals: compiled.execApprovals, - }; -}; - -describe("guided setup recovery", () => { - it("queues recoverable pending setup when local setup fails after create", () => { - const setup = createSetup(); - const pending = upsertPendingGuidedSetup({}, "agent-created", setup); - expect(pending).toEqual({ "agent-created": setup }); - }); - - it("recovers pending setups from session storage after reload", () => { - const storage = new MemoryStorage(); - const setup = createSetup(); - persistPendingGuidedSetupsToStorage({ - storage, - setupsByAgentId: { "agent-remote": setup }, - nowMs: 1_000, - }); - - const loaded = loadPendingGuidedSetupsFromStorage({ - storage, - nowMs: 2_000, - }); - - expect(loaded).toEqual({ "agent-remote": setup }); - }); - - it("retries setup against existing agent id without creating a new agent", async () => { - const setup = createSetup(); - const callLog: string[] = []; - const client = { - call: vi.fn(async (method: string) => { - callLog.push(method); - if (method === "config.get") { - return { - exists: true, - hash: "cfg-1", - config: { agents: { list: [{ id: "agent-1" }] } }, - }; - } - if (method === "config.set") return { ok: true }; - if (method === "agents.files.set") return { ok: true }; - if (method === "exec.approvals.get") { - return { - exists: true, - hash: "ap-1", - file: { version: 1, agents: {} }, - }; - } - if (method === "exec.approvals.set") return { ok: true }; - throw new Error(`unexpected method ${method}`); - }), - } as unknown as GatewayClient; - - const result = await applyPendingGuidedSetupForAgent({ - client, - agentId: "agent-1", - pendingSetupsByAgentId: { "agent-1": setup }, - }); - - expect(result.applied).toBe(true); - expect(result.pendingSetupsByAgentId).toEqual({}); - expect(callLog).not.toContain("agents.create"); - expect(callLog).toContain("config.set"); - }); - - it("removes only the applied pending setup entry on success", async () => { - const setup = createSetup(); - const otherSetup = createSetup(); - const client = { - call: vi.fn(async (method: string) => { - if (method === "config.get") { - return { - exists: true, - hash: "cfg-2", - config: { agents: { list: [{ id: "agent-1" }, { id: "agent-2" }] } }, - }; - } - if (method === "config.set") return { ok: true }; - if (method === "agents.files.set") return { ok: true }; - if (method === "exec.approvals.get") { - return { - exists: true, - hash: "ap-2", - file: { version: 1, agents: {} }, - }; - } - if (method === "exec.approvals.set") return { ok: true }; - throw new Error(`unexpected method ${method}`); - }), - } as unknown as GatewayClient; - - const result = await applyPendingGuidedSetupForAgent({ - client, - agentId: "agent-1", - pendingSetupsByAgentId: { "agent-1": setup, "agent-2": otherSetup }, - }); - - expect(result.applied).toBe(true); - expect(result.pendingSetupsByAgentId).toEqual({ "agent-2": otherSetup }); - }); - - it("retries pending setup compiled from bundle defaults", async () => { - const bundleSetup = createSetupFromBundle("pr-engineer"); - const calls: string[] = []; - const client = { - call: vi.fn(async (method: string) => { - calls.push(method); - if (method === "config.get") { - return { - exists: true, - hash: "cfg-bundle-3", - config: { agents: { list: [{ id: "agent-bundle" }] } }, - }; - } - if (method === "config.set") return { ok: true }; - if (method === "agents.files.set") return { ok: true }; - if (method === "exec.approvals.get") { - return { - exists: true, - hash: "ap-bundle-3", - file: { version: 1, agents: {} }, - }; - } - if (method === "exec.approvals.set") return { ok: true }; - throw new Error(`unexpected method ${method}`); - }), - } as unknown as GatewayClient; - - const result = await applyPendingGuidedSetupForAgent({ - client, - agentId: "agent-bundle", - pendingSetupsByAgentId: { "agent-bundle": bundleSetup }, - }); - - expect(result.applied).toBe(true); - expect(result.pendingSetupsByAgentId).toEqual({}); - expect(calls).not.toContain("agents.create"); - expect(calls).toContain("config.set"); - }); -}); diff --git a/tests/unit/historySyncOperation.integration.test.ts b/tests/unit/historySyncOperation.integration.test.ts index 9518e40..87b1c98 100644 --- a/tests/unit/historySyncOperation.integration.test.ts +++ b/tests/unit/historySyncOperation.integration.test.ts @@ -441,7 +441,8 @@ describe("historySyncOperation integration", () => { } const lines = finalUpdate.patch.outputLines ?? runningAgent.outputLines; expect(lines.filter((line) => line === "win + progress + cleanup")).toHaveLength(1); - const transcriptEntries = finalUpdate.patch.transcriptEntries ?? runningAgent.transcriptEntries; + const transcriptEntries = + finalUpdate.patch.transcriptEntries ?? runningAgent.transcriptEntries ?? []; expect( transcriptEntries.filter( (entry) => entry.kind === "assistant" && entry.text === "win + progress + cleanup" diff --git a/tests/unit/lifecycleControllerWorkflow.integration.test.ts b/tests/unit/lifecycleControllerWorkflow.integration.test.ts index 8dc0264..7a66316 100644 --- a/tests/unit/lifecycleControllerWorkflow.integration.test.ts +++ b/tests/unit/lifecycleControllerWorkflow.integration.test.ts @@ -2,15 +2,6 @@ import { describe, expect, it } from "vitest"; import type { PendingExecApproval } from "@/features/agents/approvals/types"; import { resolveExecApprovalFollowUpIntent } from "@/features/agents/approvals/execApprovalLifecycleWorkflow"; -import { - beginPendingGuidedSetupRetry, - selectNextPendingGuidedSetupRetryAgentId, -} from "@/features/agents/creation/pendingSetupRetry"; -import { - runPendingSetupRetryLifecycle, - shouldAttemptPendingSetupAutoRetry, -} from "@/features/agents/operations/pendingSetupLifecycleWorkflow"; -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; import type { AgentState } from "@/features/agents/state/store"; const createAgent = (agentId: string, sessionKey: string): AgentState => ({ @@ -64,74 +55,7 @@ const createApproval = (): PendingExecApproval => ({ error: null, }); -const createSetup = (): AgentGuidedSetup => ({ - agentOverrides: { - sandbox: { mode: "non-main", workspaceAccess: "ro" }, - tools: { profile: "coding", alsoAllow: ["group:runtime"], deny: [] }, - }, - files: {}, - execApprovals: null, -}); - describe("lifecycleControllerWorkflow integration", () => { - it("pending setup auto-retry path preserves existing guard semantics", () => { - const shouldRun = shouldAttemptPendingSetupAutoRetry({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - }); - expect(shouldRun).toBe(true); - - const inFlightAgentIds = new Set(); - const pendingSetupsByAgentId = { "agent-1": createSetup() }; - const targetAgentId = selectNextPendingGuidedSetupRetryAgentId({ - pendingSetupsByAgentId, - knownAgentIds: new Set(["agent-1"]), - attemptedAgentIds: new Set(), - inFlightAgentIds, - }); - expect(targetAgentId).toBe("agent-1"); - - const startedFirst = beginPendingGuidedSetupRetry(inFlightAgentIds, "agent-1"); - const startedSecond = beginPendingGuidedSetupRetry(inFlightAgentIds, "agent-1"); - expect(startedFirst).toBe(true); - expect(startedSecond).toBe(false); - }); - - it("manual retry failure still clears busy state and surfaces user error", async () => { - let busyAgentId: string | null = null; - let surfacedError: string | null = null; - - const runManualRetry = async (agentId: string) => { - busyAgentId = agentId; - try { - return await runPendingSetupRetryLifecycle( - { agentId, source: "manual" }, - { - executeRetry: async () => { - throw new Error("setup exploded"); - }, - isDisconnectLikeError: () => false, - resolveAgentName: () => "Agent One", - onApplied: async () => undefined, - onError: (message) => { - surfacedError = message; - }, - } - ); - } finally { - busyAgentId = busyAgentId === agentId ? null : busyAgentId; - } - }; - - const applied = await runManualRetry("agent-1"); - expect(applied).toBe(false); - expect(busyAgentId).toBeNull(); - expect(surfacedError).toBe('Guided setup retry failed for "Agent One". setup exploded'); - }); - it("allow-once and allow-always still trigger follow-up message send once", () => { const approval = createApproval(); const agents = [createAgent("agent-1", "agent:agent-1:main")]; diff --git a/tests/unit/pendingGuidedSetupAutoRetryOperation.test.ts b/tests/unit/pendingGuidedSetupAutoRetryOperation.test.ts deleted file mode 100644 index aae6160..0000000 --- a/tests/unit/pendingGuidedSetupAutoRetryOperation.test.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { runPendingGuidedSetupAutoRetryViaStudio } from "@/features/agents/operations/pendingGuidedSetupAutoRetryOperation"; - -describe("pendingGuidedSetupAutoRetryOperation", () => { - it("skips when intent is not retry", async () => { - const attempted = new Set(); - const inFlight = new Set(); - const applyRetry = vi.fn(async () => true); - - const result = await runPendingGuidedSetupAutoRetryViaStudio({ - status: "disconnected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - pendingSetupsByAgentId: { a1: {} as unknown as AgentGuidedSetup }, - knownAgentIds: new Set(["a1"]), - attemptedAgentIds: attempted, - inFlightAgentIds: inFlight, - applyRetry, - }); - - expect(result).toBe(false); - expect(applyRetry).not.toHaveBeenCalled(); - expect(attempted.size).toBe(0); - }); - - it("marks attempted and triggers retry", async () => { - const attempted = new Set(); - const inFlight = new Set(); - const applyRetry = vi.fn(async () => true); - - const result = await runPendingGuidedSetupAutoRetryViaStudio({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - pendingSetupsByAgentId: { a1: {} as unknown as AgentGuidedSetup }, - knownAgentIds: new Set(["a1"]), - attemptedAgentIds: attempted, - inFlightAgentIds: inFlight, - applyRetry, - }); - - expect(result).toBe(true); - expect(applyRetry).toHaveBeenCalledWith("a1"); - expect(attempted.has("a1")).toBe(true); - }); -}); - diff --git a/tests/unit/pendingGuidedSetupRetry.test.ts b/tests/unit/pendingGuidedSetupRetry.test.ts deleted file mode 100644 index 4bd43e4..0000000 --- a/tests/unit/pendingGuidedSetupRetry.test.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { - beginPendingGuidedSetupRetry, - endPendingGuidedSetupRetry, - selectNextPendingGuidedSetupRetryAgentId, -} from "@/features/agents/creation/pendingSetupRetry"; - -const createSetup = (): AgentGuidedSetup => ({ - agentOverrides: { - sandbox: { mode: "non-main", workspaceAccess: "ro" }, - tools: { profile: "coding", alsoAllow: ["group:runtime"], deny: ["group:web"] }, - }, - files: { - "AGENTS.md": "# Mission", - }, - execApprovals: { - security: "allowlist", - ask: "always", - allowlist: [{ pattern: "/usr/bin/git" }], - }, -}); - -describe("pending guided setup retry coordination", () => { - it("selects next retry target while skipping unknown, attempted, and in-flight entries", () => { - const pendingSetupsByAgentId = { - "agent-c": createSetup(), - "agent-b": createSetup(), - "agent-a": createSetup(), - }; - - const next = selectNextPendingGuidedSetupRetryAgentId({ - pendingSetupsByAgentId, - knownAgentIds: new Set(["agent-a", "agent-b"]), - attemptedAgentIds: new Set(["agent-a"]), - inFlightAgentIds: new Set(), - }); - - expect(next).toBe("agent-b"); - }); - - it("returns deterministic ordering for stable input", () => { - const pendingSetupsByAgentId = { - "agent-z": createSetup(), - "agent-m": createSetup(), - "agent-a": createSetup(), - }; - const params = { - pendingSetupsByAgentId, - knownAgentIds: new Set(["agent-z", "agent-m", "agent-a"]), - attemptedAgentIds: new Set(), - inFlightAgentIds: new Set(), - }; - - const first = selectNextPendingGuidedSetupRetryAgentId(params); - const second = selectNextPendingGuidedSetupRetryAgentId(params); - - expect(first).toBe("agent-a"); - expect(second).toBe("agent-a"); - }); - - it("uses in-flight guards to prevent duplicate starts", () => { - const inFlight = new Set(); - - const firstStart = beginPendingGuidedSetupRetry(inFlight, "agent-1"); - const secondStart = beginPendingGuidedSetupRetry(inFlight, "agent-1"); - - expect(firstStart).toBe(true); - expect(secondStart).toBe(false); - expect(inFlight.has("agent-1")).toBe(true); - - endPendingGuidedSetupRetry(inFlight, "agent-1"); - expect(inFlight.has("agent-1")).toBe(false); - }); -}); diff --git a/tests/unit/pendingGuidedSetupRetryOperation.test.ts b/tests/unit/pendingGuidedSetupRetryOperation.test.ts deleted file mode 100644 index 796c04b..0000000 --- a/tests/unit/pendingGuidedSetupRetryOperation.test.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { applyPendingGuidedSetupRetryViaStudio } from "@/features/agents/operations/pendingGuidedSetupRetryOperation"; - -type SetState = (next: T | ((current: T) => T)) => void; - -const createState = (initial: T): { get: () => T; set: SetState } => { - let value = initial; - return { - get: () => value, - set: (next) => { - value = typeof next === "function" ? (next as (current: T) => T)(value) : next; - }, - }; -}; - -describe("pendingGuidedSetupRetryOperation", () => { - it("returns false when another retry is busy", async () => { - const busy = createState("other"); - const inFlight = new Set(); - - const result = await applyPendingGuidedSetupRetryViaStudio({ - agentId: "a1", - source: "manual", - retryBusyAgentId: busy.get(), - inFlightAgentIds: inFlight, - pendingSetupsByAgentId: { a1: {} as unknown as AgentGuidedSetup }, - setRetryBusyAgentId: busy.set, - executeRetry: vi.fn(), - isDisconnectLikeError: () => false, - resolveAgentName: (agentId) => agentId, - onApplied: vi.fn(), - onError: vi.fn(), - }); - - expect(result).toBe(false); - expect(inFlight.size).toBe(0); - expect(busy.get()).toBe("other"); - }); - - it("returns false and releases in-flight when setup is missing", async () => { - const busy = createState(null); - const inFlight = new Set(); - - const result = await applyPendingGuidedSetupRetryViaStudio({ - agentId: "a1", - source: "manual", - retryBusyAgentId: busy.get(), - inFlightAgentIds: inFlight, - pendingSetupsByAgentId: {}, - setRetryBusyAgentId: busy.set, - executeRetry: vi.fn(), - isDisconnectLikeError: () => false, - resolveAgentName: (agentId) => agentId, - onApplied: vi.fn(), - onError: vi.fn(), - }); - - expect(result).toBe(false); - expect(inFlight.has("a1")).toBe(false); - expect(busy.get()).toBe(null); - }); - - it("runs lifecycle and clears busy/in-flight after completion", async () => { - const busy = createState(null); - const setBusy = vi.fn(busy.set); - const inFlight = new Set(); - const executeRetry = vi.fn(async () => ({ applied: true })); - const onApplied = vi.fn(); - const onError = vi.fn(); - - const result = await applyPendingGuidedSetupRetryViaStudio({ - agentId: "a1", - source: "manual", - retryBusyAgentId: busy.get(), - inFlightAgentIds: inFlight, - pendingSetupsByAgentId: { a1: {} as unknown as AgentGuidedSetup }, - setRetryBusyAgentId: setBusy, - executeRetry, - isDisconnectLikeError: () => false, - resolveAgentName: (agentId) => agentId, - onApplied, - onError, - }); - - expect(result).toBe(true); - expect(executeRetry).toHaveBeenCalledWith("a1"); - expect(onApplied).toHaveBeenCalledTimes(1); - expect(onError).not.toHaveBeenCalled(); - expect(inFlight.has("a1")).toBe(false); - expect(busy.get()).toBe(null); - expect(setBusy).toHaveBeenCalled(); - }); -}); - diff --git a/tests/unit/pendingGuidedSetupSessionStorageLifecycle.test.ts b/tests/unit/pendingGuidedSetupSessionStorageLifecycle.test.ts deleted file mode 100644 index ab9ac5f..0000000 --- a/tests/unit/pendingGuidedSetupSessionStorageLifecycle.test.ts +++ /dev/null @@ -1,68 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; - -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { - loadPendingGuidedSetupsForScope, - persistPendingGuidedSetupsForScopeWhenLoaded, -} from "@/features/agents/creation/pendingGuidedSetupSessionStorageLifecycle"; -import { persistPendingGuidedSetupsToStorage } from "@/features/agents/creation/pendingSetupStore"; - -const createMemoryStorage = () => { - const data = new Map(); - return { - getItem: (key: string) => data.get(key) ?? null, - setItem: (key: string, value: string) => { - data.set(key, value); - }, - removeItem: (key: string) => { - data.delete(key); - }, - }; -}; - -describe("pendingGuidedSetupSessionStorageLifecycle", () => { - it("loads pending setups for a scope and returns the scope marker", () => { - const storage = createMemoryStorage(); - const setup: AgentGuidedSetup = { - agentOverrides: {}, - files: {}, - execApprovals: null, - }; - - persistPendingGuidedSetupsToStorage({ - storage: storage as unknown as Storage, - gatewayScope: "scope-a", - setupsByAgentId: { a1: setup }, - nowMs: Date.now(), - }); - - const loaded = loadPendingGuidedSetupsForScope({ - storage: storage as unknown as Storage, - gatewayScope: "scope-a", - }); - - expect(loaded.loadedScope).toBe("scope-a"); - expect(Object.keys(loaded.setupsByAgentId)).toEqual(["a1"]); - }); - - it("does not persist when loaded scope mismatches", () => { - const storage = createMemoryStorage(); - const setItem = vi.spyOn(storage, "setItem"); - const removeItem = vi.spyOn(storage, "removeItem"); - const setup: AgentGuidedSetup = { - agentOverrides: {}, - files: {}, - execApprovals: null, - }; - - persistPendingGuidedSetupsForScopeWhenLoaded({ - storage: storage as unknown as Storage, - gatewayScope: "scope-a", - loadedScope: "scope-b", - setupsByAgentId: { a1: setup }, - }); - - expect(setItem).not.toHaveBeenCalled(); - expect(removeItem).not.toHaveBeenCalled(); - }); -}); diff --git a/tests/unit/pendingGuidedSetupStore.test.ts b/tests/unit/pendingGuidedSetupStore.test.ts deleted file mode 100644 index 06b2d5e..0000000 --- a/tests/unit/pendingGuidedSetupStore.test.ts +++ /dev/null @@ -1,252 +0,0 @@ -import { describe, expect, it } from "vitest"; -import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation"; -import { - loadPendingGuidedSetupsFromStorage, - PENDING_GUIDED_SETUP_MAX_AGE_MS, - PENDING_GUIDED_SETUP_SESSION_KEY, - persistPendingGuidedSetupsToStorage, -} from "@/features/agents/creation/pendingSetupStore"; - -class MemoryStorage implements Storage { - private readonly map = new Map(); - - get length() { - return this.map.size; - } - - clear(): void { - this.map.clear(); - } - - getItem(key: string): string | null { - return this.map.get(key) ?? null; - } - - key(index: number): string | null { - return Array.from(this.map.keys())[index] ?? null; - } - - removeItem(key: string): void { - this.map.delete(key); - } - - setItem(key: string, value: string): void { - this.map.set(key, value); - } -} - -const createSetup = (): AgentGuidedSetup => ({ - agentOverrides: { - sandbox: { mode: "non-main", workspaceAccess: "ro" }, - tools: { profile: "coding", alsoAllow: ["group:runtime"], deny: ["group:web"] }, - }, - files: { - "AGENTS.md": "# Mission", - }, - execApprovals: { - security: "allowlist", - ask: "always", - allowlist: [{ pattern: "/usr/bin/git" }], - }, -}); - -describe("pendingGuidedSetupStore", () => { - it("persists and loads pending setups by agent id for the requested gateway scope", () => { - const storage = new MemoryStorage(); - const setup = createSetup(); - persistPendingGuidedSetupsToStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - setupsByAgentId: { "agent-1": setup }, - nowMs: 2_000, - }); - - const loaded = loadPendingGuidedSetupsFromStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - nowMs: 2_500, - }); - - expect(loaded).toEqual({ "agent-1": setup }); - }); - - it("preserves entries for other gateway scopes when persisting", () => { - const storage = new MemoryStorage(); - const setupA = createSetup(); - const setupB = createSetup(); - - persistPendingGuidedSetupsToStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - setupsByAgentId: { "agent-a": setupA }, - nowMs: 1_000, - }); - persistPendingGuidedSetupsToStorage({ - storage, - gatewayScope: "ws://gateway-b:18789", - setupsByAgentId: { "agent-b": setupB }, - nowMs: 1_100, - }); - - const loadedA = loadPendingGuidedSetupsFromStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - nowMs: 1_200, - }); - const loadedB = loadPendingGuidedSetupsFromStorage({ - storage, - gatewayScope: "ws://gateway-b:18789", - nowMs: 1_200, - }); - - expect(loadedA).toEqual({ "agent-a": setupA }); - expect(loadedB).toEqual({ "agent-b": setupB }); - }); - - it("removes only the requested gateway scope entries when persisting an empty map", () => { - const storage = new MemoryStorage(); - const setupA = createSetup(); - const setupB = createSetup(); - - persistPendingGuidedSetupsToStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - setupsByAgentId: { "agent-a": setupA }, - nowMs: 1_000, - }); - persistPendingGuidedSetupsToStorage({ - storage, - gatewayScope: "ws://gateway-b:18789", - setupsByAgentId: { "agent-b": setupB }, - nowMs: 1_100, - }); - - persistPendingGuidedSetupsToStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - setupsByAgentId: {}, - nowMs: 1_200, - }); - - const loadedA = loadPendingGuidedSetupsFromStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - nowMs: 1_300, - }); - const loadedB = loadPendingGuidedSetupsFromStorage({ - storage, - gatewayScope: "ws://gateway-b:18789", - nowMs: 1_300, - }); - - expect(loadedA).toEqual({}); - expect(loadedB).toEqual({ "agent-b": setupB }); - }); - - it("ignores malformed JSON and unknown shapes", () => { - const storage = new MemoryStorage(); - storage.setItem(PENDING_GUIDED_SETUP_SESSION_KEY, "not-json"); - expect(loadPendingGuidedSetupsFromStorage({ storage, gatewayScope: "ws://gateway-a:18789" })).toEqual({}); - - storage.setItem( - PENDING_GUIDED_SETUP_SESSION_KEY, - JSON.stringify({ - version: 1, - entries: [ - { - agentId: "", - gatewayScope: "ws://gateway-a:18789", - setup: {}, - savedAtMs: 1_000, - }, - ], - }) - ); - expect( - loadPendingGuidedSetupsFromStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - nowMs: 2_000, - }) - ).toEqual({}); - }); - - it("drops stale entries using max age", () => { - const storage = new MemoryStorage(); - const setup = createSetup(); - storage.setItem( - PENDING_GUIDED_SETUP_SESSION_KEY, - JSON.stringify({ - version: 1, - entries: [ - { - agentId: "agent-1", - gatewayScope: "ws://gateway-a:18789", - setup, - savedAtMs: 1_000, - }, - ], - }) - ); - - const loaded = loadPendingGuidedSetupsFromStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - nowMs: 1_000 + PENDING_GUIDED_SETUP_MAX_AGE_MS + 1, - }); - - expect(loaded).toEqual({}); - }); - - it("removes storage key when no pending setups remain for any scope", () => { - const storage = new MemoryStorage(); - storage.setItem(PENDING_GUIDED_SETUP_SESSION_KEY, "{}"); - - persistPendingGuidedSetupsToStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - setupsByAgentId: {}, - }); - - expect(storage.getItem(PENDING_GUIDED_SETUP_SESSION_KEY)).toBeNull(); - }); - - it("fails safe when storage methods throw", () => { - class ThrowingStorage extends MemoryStorage { - override getItem(): string | null { - throw new Error("getItem failed"); - } - override setItem(): void { - throw new Error("setItem failed"); - } - override removeItem(): void { - throw new Error("removeItem failed"); - } - } - - const storage = new ThrowingStorage(); - - expect(() => - loadPendingGuidedSetupsFromStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - }) - ).not.toThrow(); - - expect(() => - persistPendingGuidedSetupsToStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - setupsByAgentId: { "agent-1": createSetup() }, - }) - ).not.toThrow(); - - expect(() => - persistPendingGuidedSetupsToStorage({ - storage, - gatewayScope: "ws://gateway-a:18789", - setupsByAgentId: {}, - }) - ).not.toThrow(); - }); -}); diff --git a/tests/unit/pendingSetupLifecycleWorkflow.test.ts b/tests/unit/pendingSetupLifecycleWorkflow.test.ts deleted file mode 100644 index 0e2fae2..0000000 --- a/tests/unit/pendingSetupLifecycleWorkflow.test.ts +++ /dev/null @@ -1,125 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { - buildPendingSetupRetryErrorMessage, - runPendingSetupRetryLifecycle, - shouldAttemptPendingSetupAutoRetry, - shouldSuppressPendingSetupRetryError, -} from "@/features/agents/operations/pendingSetupLifecycleWorkflow"; - -describe("pendingSetupLifecycleWorkflow", () => { - it("allows auto-retry only when connected, loaded, and not blocked", () => { - expect( - shouldAttemptPendingSetupAutoRetry({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - }) - ).toBe(true); - - expect( - shouldAttemptPendingSetupAutoRetry({ - status: "connecting", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - }) - ).toBe(false); - expect( - shouldAttemptPendingSetupAutoRetry({ - status: "connected", - agentsLoadedOnce: false, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - }) - ).toBe(false); - expect( - shouldAttemptPendingSetupAutoRetry({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: false, - hasActiveCreateBlock: false, - retryBusyAgentId: null, - }) - ).toBe(false); - expect( - shouldAttemptPendingSetupAutoRetry({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: true, - retryBusyAgentId: null, - }) - ).toBe(false); - expect( - shouldAttemptPendingSetupAutoRetry({ - status: "connected", - agentsLoadedOnce: true, - loadedScopeMatches: true, - hasActiveCreateBlock: false, - retryBusyAgentId: "agent-1", - }) - ).toBe(false); - }); - - it("resolves manual retry failure message with agent name and original error", () => { - expect( - buildPendingSetupRetryErrorMessage({ - source: "manual", - agentName: "Agent One", - errorMessage: "setup exploded", - }) - ).toBe('Guided setup retry failed for "Agent One". setup exploded'); - }); - - it("suppresses auto-retry disconnect-like failures without surfacing user error", () => { - expect( - shouldSuppressPendingSetupRetryError({ - source: "auto", - disconnectLike: true, - }) - ).toBe(true); - - expect( - shouldSuppressPendingSetupRetryError({ - source: "manual", - disconnectLike: true, - }) - ).toBe(false); - - expect( - shouldSuppressPendingSetupRetryError({ - source: "auto", - disconnectLike: false, - }) - ).toBe(false); - }); - - it("rejects empty agent id before retry side effects", async () => { - const executeRetry = vi.fn(async () => ({ applied: true })); - const onApplied = vi.fn(async () => undefined); - const onError = vi.fn(); - - const result = await runPendingSetupRetryLifecycle( - { - agentId: " ", - source: "manual", - }, - { - executeRetry, - isDisconnectLikeError: () => false, - resolveAgentName: () => "unused", - onApplied, - onError, - } - ); - - expect(result).toBe(false); - expect(executeRetry).not.toHaveBeenCalled(); - expect(onApplied).not.toHaveBeenCalled(); - expect(onError).not.toHaveBeenCalled(); - }); -});