Merge pull request #65 from grp06/codex/simplify-agent-creation-flow

Refine agent creation onboarding and lifecycle handling
This commit is contained in:
George Pickett
2026-02-17 16:48:55 -08:00
committed by GitHub
34 changed files with 217 additions and 4828 deletions
+8 -10
View File
@@ -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: <CODE> ...`) 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.
+33 -72
View File
@@ -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)
Studios 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: "<stateDir>/workspace-<slug>" })
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 Studios 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 gateways 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)
+5 -239
View File
@@ -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<string, AgentGuidedSetup>
>({});
const [pendingCreateSetupsLoadedScope, setPendingCreateSetupsLoadedScope] = useState<
string | null
>(null);
const [retryPendingSetupBusyAgentId, setRetryPendingSetupBusyAgentId] = useState<string | null>(
null
);
const specialUpdateRef = useRef<Map<string, string>>(new Map());
const seenCronEventIdsRef = useRef<Set<string>>(new Set());
const preferredSelectedAgentIdRef = useRef<string | null>(null);
const pendingCreateSetupsByAgentIdRef = useRef<Record<string, AgentGuidedSetup>>({});
const pendingDraftValuesRef = useRef<Map<string, string>>(new Map());
const pendingDraftTimersRef = useRef<Map<string, number>>(new Map());
const pendingLivePatchesRef = useRef<Map<string, Partial<AgentState>>>(new Map());
@@ -281,8 +252,6 @@ const AgentStudioPage = () => {
null
);
const reconcileRunInFlightRef = useRef<Set<string>>(new Set());
const pendingSetupAutoRetryAttemptedRef = useRef<Set<string>>(new Set());
const pendingSetupAutoRetryInFlightRef = useRef<Set<string>>(new Set());
const approvalPausedRunIdByAgentRef = useRef<Map<string, string>>(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 ? (
<div className="flex min-h-0 flex-1 flex-col">
{focusedPendingCreateSetup ? (
<div
className="mx-3 mt-3 rounded-md border border-amber-500/40 bg-amber-500/12 px-3 py-2 sm:mx-4"
data-testid="pending-guided-setup-card"
>
<div className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-amber-900">
Guided setup pending
</div>
<div className="mt-1 text-[11px] text-muted-foreground">
This agent was created, but setup did not finish. Retry setup now or discard
the pending setup and keep the current agent state.
</div>
<div className="mt-2 flex flex-wrap gap-2">
<button
type="button"
className="rounded-[8px] border border-border/70 bg-surface-3 px-2.5 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => {
void handleRetryPendingCreateSetup(focusedAgent.agentId);
}}
disabled={status !== "connected" || focusedPendingCreateSetupBusy}
>
{focusedPendingCreateSetupBusy ? "Applying..." : "Retry setup"}
</button>
<button
type="button"
className="rounded-[8px] border border-border/70 bg-surface-3 px-2.5 py-1 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-foreground transition hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
onClick={() => handleDiscardPendingCreateSetup(focusedAgent.agentId)}
disabled={focusedPendingCreateSetupBusy}
>
Discard pending setup
</button>
</div>
</div>
) : null}
<div className="min-h-0 flex-1">
<AgentChatPanel
agent={focusedAgent}
@@ -2725,7 +2492,6 @@ const AgentStudioPage = () => {
</div>
{createAgentModalOpen ? (
<AgentCreateModal
key={suggestedCreateAgentName}
open={createAgentModalOpen}
suggestedName={suggestedCreateAgentName}
busy={createAgentBusy}
@@ -1,31 +1,10 @@
"use client";
import { useMemo, useState } from "react";
import {
ChartLine,
Compass,
Layers,
ListChecks,
type LucideIcon,
Shuffle,
TrendingUp,
Workflow,
} from "lucide-react";
import {
compileGuidedAgentCreation,
createDefaultGuidedDraft,
hasGuidedGroupCapability,
resolveGuidedControlsForPreset,
resolveGuidedDraftFromPresetBundle,
} from "@/features/agents/creation/compiler";
import type {
AgentCreateModalSubmitPayload,
AgentPresetBundle,
GuidedCreationControls,
GuidedAgentCreationDraft,
} from "@/features/agents/creation/types";
import { randomUUID } from "@/lib/uuid";
import { useEffect, useMemo, useRef, useState } from "react";
import { Shuffle } from "lucide-react";
import type { AgentCreateModalSubmitPayload } from "@/features/agents/creation/types";
import { AgentAvatar } from "@/features/agents/components/AgentAvatar";
import { randomUUID } from "@/lib/uuid";
type AgentCreateModalProps = {
open: boolean;
@@ -40,248 +19,11 @@ const fieldClassName =
"w-full rounded-md border border-border/80 bg-surface-3 px-3 py-2 text-xs text-foreground outline-none";
const labelClassName =
"font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground";
const controlOptionClassName = (
selected: boolean,
emphasis: "neutral" | "guided" | "full" = "neutral"
): string => {
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<string, string> = {
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<GuidedAgentCreationDraft>(() => {
const seed = createDefaultGuidedDraft();
if (!defaultDirection) return seed;
return resolveGuidedDraftFromPresetBundle({
bundle: defaultDirection.bundle,
seed,
});
});
const [selectedDirectionId, setSelectedDirectionId] = useState<string>(
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}
>
<div
className="w-full max-w-4xl rounded-lg border border-border bg-card"
<form
className="w-full max-w-2xl rounded-lg border border-border bg-card"
onSubmit={(event) => {
event.preventDefault();
handleSubmit();
}}
onClick={(event) => event.stopPropagation()}
data-testid="agent-create-modal"
>
@@ -580,8 +80,8 @@ export const AgentCreateModal = ({
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.16em] text-muted-foreground">
New Agent
</div>
<div className="mt-1 text-base font-semibold text-foreground">{stepHeader.title}</div>
<div className="mt-1 text-xs text-muted-foreground">{stepHeader.subtext}</div>
<div className="mt-1 text-base font-semibold text-foreground">Launch Agent</div>
<div className="mt-1 text-xs text-muted-foreground">Name it and activate immediately.</div>
</div>
<button
type="button"
@@ -593,323 +93,57 @@ export const AgentCreateModal = ({
</button>
</div>
<div className="h-[clamp(440px,62vh,620px)] overflow-y-auto px-5 py-4 [scrollbar-gutter:stable]">
{stepKey === "starter" ? (
<div className="grid gap-3" data-testid="agent-create-starter-step">
<div className="text-sm text-muted-foreground">
What does this agent fully own?
</div>
<div className="grid gap-3 md:grid-cols-2">
{STARTER_DIRECTIONS.map((direction) => {
const Icon = direction.icon;
const isSelected = selectedDirectionId === direction.id;
return (
<button
key={direction.id}
type="button"
aria-label={`${direction.title} role`}
className={`min-h-[108px] rounded-md border border-l-2 px-4 py-2 text-left transition duration-150 ease-out ${
isSelected
? `border-2 border-primary/60 bg-surface-2/95 ${direction.railClassName} shadow-md`
: `border-border/80 bg-surface-1 ${direction.railClassName} hover:border-border hover:bg-surface-2 hover:shadow-sm`
}`}
onClick={() => updatePresetBundle(direction)}
>
<div className="flex items-center gap-2">
<span className="inline-flex h-6 w-6 items-center justify-center rounded-sm border border-border/70 bg-surface-2/95 shadow-inner">
<Icon
className={`h-3.5 w-3.5 ${
direction.iconClassName
}`}
strokeWidth={1.5}
/>
</span>
<div
className={`font-mono text-[11px] uppercase tracking-[0.12em] ${
isSelected ? "font-bold text-foreground" : "font-semibold text-muted-foreground"
}`}
>
{direction.title}
</div>
{direction.id === "execution" ? (
<span className="rounded border border-border/70 bg-surface-2 px-2 py-0.5 text-[11px] text-muted-foreground">
Default starting point
</span>
) : null}
</div>
<div className="mt-2 text-sm text-foreground">{direction.description}</div>
</button>
);
})}
</div>
<div className="rounded-md border border-border/80 bg-surface-1 px-4 py-3">
<div className="text-xs font-semibold text-foreground">
This agent will be accountable for:
</div>
<ul className="mt-2 list-disc pl-4 text-xs text-muted-foreground">
{selectedDirection.accountabilityPoints.map((point) => (
<li key={`${selectedDirection.id}-${point}`}>{point}</li>
))}
</ul>
</div>
</div>
) : null}
{stepKey === "control" ? (
<div className="grid gap-3" data-testid="agent-create-control-step">
<div className="grid gap-3 md:grid-cols-3">
{AUTONOMY_PROFILES.map((profile) => (
<button
key={profile.id}
type="button"
aria-label={`${profile.title} autonomy profile`}
className={`rounded-md border px-4 py-4 text-left transition ${
selectedAutonomyProfile === profile.id
? "border-primary/60 bg-surface-2 shadow-sm"
: "border-border/80 bg-surface-1 hover:border-border hover:bg-surface-2"
}`}
onClick={() => applyAutonomyProfile(profile.id)}
>
<div className="flex items-center justify-between gap-2">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
{profile.title}
</div>
{profile.id === "collaborative" ? (
<span className="rounded border border-primary/35 bg-primary/20 px-2 py-0.5 text-[11px] font-semibold text-primary">
Recommended
</span>
) : null}
</div>
<div className="mt-2 text-xs text-foreground">{profile.description}</div>
<ul className="mt-2 list-disc pl-4 text-xs text-muted-foreground">
{profile.details.map((detail) => (
<li key={`${profile.id}-${detail}`}>{detail}</li>
))}
</ul>
</button>
))}
</div>
<button
type="button"
aria-label={showCapabilityOverrides ? "Hide fine-tune capabilities" : "Show fine-tune capabilities"}
className="rounded-md border border-border/70 bg-surface-1 px-2.5 py-1.5 text-left font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground transition hover:border-border hover:bg-surface-2"
onClick={() => setShowCapabilityOverrides((current) => !current)}
>
{showCapabilityOverrides
? "Hide fine-tune capabilities"
: "Fine-tune capabilities (optional)"}
</button>
{showCapabilityOverrides ? (
<div className="grid gap-2.5 rounded-md border border-border/70 bg-surface-1/70 p-2.5">
<div className="grid gap-0.5">
<div className={labelClassName}>Web Access</div>
<div className="text-[11px] text-muted-foreground">Internet research and fetch tools.</div>
<div className="grid gap-2 md:grid-cols-2">
<button
type="button"
aria-label="Web access off"
className={controlOptionClassName(!webAccessEnabled, "neutral")}
onClick={() => updateWebAccess(false)}
>
Off
</button>
<button
type="button"
aria-label="Web access on"
className={controlOptionClassName(webAccessEnabled, "guided")}
onClick={() => updateWebAccess(true)}
>
On
</button>
</div>
</div>
<div className="grid gap-0.5">
<div className={labelClassName}>Code and Files</div>
<div className="text-[11px] text-muted-foreground">Controls codebase and file modification behavior.</div>
<div className="grid gap-2 md:grid-cols-1">
<button
type="button"
aria-label="File changes on"
className={controlOptionClassName(true, "full")}
onClick={() => updateFileMode("on")}
>
On
</button>
</div>
<div
className="min-h-4 text-[11px] text-muted-foreground"
aria-hidden={false}
>
Can modify your codebase directly.
</div>
</div>
<div className="grid gap-0.5">
<div className={labelClassName}>System Access</div>
<div className="text-[11px] text-muted-foreground">Controls system actions and command execution.</div>
<div className="grid gap-2 md:grid-cols-3">
<button
type="button"
aria-label="Command execution off"
className={controlOptionClassName(commandMode === "off", "neutral")}
onClick={() => updateCommandMode("off")}
>
Off
</button>
<button
type="button"
aria-label="Command execution ask first"
className={controlOptionClassName(commandMode === "ask-first", "guided")}
onClick={() => updateCommandMode("ask-first")}
>
Ask first
</button>
<button
type="button"
aria-label="Command execution auto"
className={controlOptionClassName(commandMode === "auto", "full")}
onClick={() => updateCommandMode("auto")}
>
Auto
</button>
</div>
<div
className={`min-h-4 text-[11px] ${
commandMode === "auto" ? "text-muted-foreground" : "text-transparent"
}`}
aria-hidden={commandMode !== "auto"}
>
{commandMode === "auto" ? "Can operate your system automatically." : "\u00A0"}
</div>
</div>
</div>
) : null}
</div>
) : null}
{stepKey === "customize" ? (
<div className="grid gap-4" data-testid="agent-create-customize-step">
<div className="grid gap-4">
<div className="grid gap-1">
<div className="text-sm font-semibold text-foreground">Activation begins immediately.</div>
</div>
<div className="rounded-md border border-border/80 bg-surface-1 px-3 py-2">
<div className="text-xs font-semibold text-foreground">This agent will:</div>
<div className="mt-2 grid gap-1.5 text-xs text-foreground">
<div className="grid grid-cols-[76px_minmax(0,1fr)] gap-2">
<span className="text-muted-foreground">Own:</span>
<span>
{selectedDirection.title} - {selectedDirection.description.replace(/^Owns\s+/i, "").replace(/\.$/, "")}
</span>
</div>
<div className="grid grid-cols-[76px_minmax(0,1fr)] gap-2">
<span className="text-muted-foreground">Authority:</span>
<span>{authoritySummary}</span>
</div>
</div>
<div className="mt-3 text-xs font-semibold text-foreground">On launch it will:</div>
<ul className="mt-1.5 list-disc pl-4 text-xs text-muted-foreground">
{selectedDirection.launchActions.map((action) => (
<li key={`${selectedDirection.id}-${action}`}>{action}</li>
))}
</ul>
</div>
<label className={labelClassName}>
Agent name
<input
value={name}
onChange={(event) => {
setNameWasEdited(true);
setName(event.target.value);
}}
className={`mt-1 ${fieldClassName}`}
placeholder="My agent"
/>
</label>
<div className="-mt-2 text-[11px] text-muted-foreground">
Name reflects its role. You can change it later.
</div>
<div className="grid justify-items-center gap-2 border-t border-border/70 pt-3">
<div className={labelClassName}>Choose avatar</div>
<AgentAvatar
seed={avatarSeed}
name={name.trim() || "New Agent"}
size={64}
isSelected
/>
<button
type="button"
aria-label="Shuffle avatar selection"
className="inline-flex items-center gap-2 rounded-md border border-border/80 bg-surface-3 px-3 py-2 text-xs text-muted-foreground transition hover:border-border hover:bg-surface-2"
onClick={() => setAvatarSeed(randomUUID())}
>
<Shuffle className="h-3.5 w-3.5" />
Shuffle
</button>
<div className="text-center text-[11px] text-muted-foreground">
You can rename or change the avatar later.
</div>
</div>
</div>
</div>
) : null}
<div className="grid gap-4 px-5 py-4">
<label className={labelClassName}>
Agent name
<input
value={name}
onChange={(event) => setName(event.target.value)}
className={`mt-1 ${fieldClassName}`}
placeholder="My agent"
/>
</label>
<div className="-mt-2 text-[11px] text-muted-foreground">
You can rename this agent later in settings.
</div>
<div className="grid justify-items-center gap-2 border-t border-border/70 pt-3">
<div className={labelClassName}>Choose avatar</div>
<AgentAvatar
seed={avatarSeed}
name={name.trim() || "New Agent"}
size={64}
isSelected
/>
<button
type="button"
aria-label="Shuffle avatar selection"
className="inline-flex items-center gap-2 rounded-md border border-border/80 bg-surface-3 px-3 py-2 text-xs text-muted-foreground transition hover:border-border hover:bg-surface-2"
onClick={() => setAvatarSeed(randomUUID())}
disabled={busy}
>
<Shuffle className="h-3.5 w-3.5" />
Shuffle
</button>
</div>
{submitError ? (
<div className="mt-4 rounded-md border border-destructive/50 bg-destructive/12 px-3 py-2 text-xs text-destructive">
<div className="rounded-md border border-destructive/50 bg-destructive/12 px-3 py-2 text-xs text-destructive">
{submitError}
</div>
) : null}
</div>
<div className="flex items-center justify-between border-t border-border/80 px-5 py-3">
{stepKey === "customize" ? (
<div />
) : (
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground">
Step {stepIndex + 1} of {steps.length}
</div>
)}
{stepKey === "customize" ? (
<div className="grid justify-items-end gap-1">
<div className="text-[11px] text-muted-foreground">
You can adjust ownership and authority later.
</div>
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-md border border-border/80 bg-surface-3 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-muted-foreground transition hover:border-border hover:bg-surface-2 disabled:cursor-not-allowed disabled:opacity-60"
onClick={moveBack}
disabled={stepIndex === 0 || busy}
>
Back
</button>
<button
type="button"
className="rounded-md border border-transparent bg-primary px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-foreground transition hover:brightness-105 disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
onClick={handleSubmit}
disabled={!canSubmit || busy}
>
{busy ? "Launching..." : "Launch agent"}
</button>
</div>
</div>
) : (
<div className="flex items-center gap-2">
<button
type="button"
className="rounded-md border border-transparent bg-primary px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-foreground transition hover:brightness-105 disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
onClick={moveNext}
disabled={!canGoNext || busy}
>
Next
</button>
</div>
)}
<div className="text-[11px] text-muted-foreground">Authority can be configured after launch.</div>
<button
type="submit"
className="rounded-md border border-transparent bg-primary px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-primary-foreground transition hover:brightness-105 disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
disabled={!canSubmit || busy}
>
{busy ? "Launching..." : "Launch agent"}
</button>
</div>
</div>
</form>
</div>
);
};
-547
View File
@@ -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<AgentStarterKit, StarterTemplate> = {
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<AgentControlLevel, ControlDefaults> = {
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<AgentPresetBundle, GuidedPresetBundleDefinition> = {
"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<GuidedCreationControls["toolsProfile"], string[]> = {
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<Record<AgentFileName, string>> = {
"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,
};
};
@@ -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<string, AgentGuidedSetup>;
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<string, AgentGuidedSetup>;
}): void => {
if (params.loadedScope !== params.gatewayScope) return;
persistPendingGuidedSetupsToStorage({
storage: params.storage,
gatewayScope: params.gatewayScope,
setupsByAgentId: params.setupsByAgentId,
});
};
@@ -1,38 +0,0 @@
import type { AgentGuidedSetup } from "@/features/agents/operations/createAgentOperation";
type RetrySelectionParams = {
pendingSetupsByAgentId: Record<string, AgentGuidedSetup>;
knownAgentIds: Set<string>;
attemptedAgentIds: Set<string>;
inFlightAgentIds: Set<string>;
};
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<string>, 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<string>, agentId: string): void => {
const resolvedAgentId = agentId.trim();
if (!resolvedAgentId) return;
inFlightAgentIds.delete(resolvedAgentId);
};
@@ -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<string, unknown> =>
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<string, AgentGuidedSetup> => {
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<string, AgentGuidedSetup> = {};
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<string, AgentGuidedSetup>;
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));
};
-61
View File
@@ -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<string, AgentGuidedSetup>,
agentId: string,
setup: AgentGuidedSetup
): Record<string, AgentGuidedSetup> => {
const id = agentId.trim();
if (!id) return pendingSetupsByAgentId;
return {
...pendingSetupsByAgentId,
[id]: setup,
};
};
export const removePendingGuidedSetup = (
pendingSetupsByAgentId: Record<string, AgentGuidedSetup>,
agentId: string
): Record<string, AgentGuidedSetup> => {
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<string, AgentGuidedSetup>;
}): Promise<{ applied: boolean; pendingSetupsByAgentId: Record<string, AgentGuidedSetup> }> => {
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),
};
};
-92
View File
@@ -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<Record<AgentFileName, string>>;
agentOverrides: GatewayAgentOverrides;
execApprovals: GuidedExecApprovalsPolicy | null;
validation: {
errors: string[];
warnings: string[];
};
summary: string[];
};
@@ -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<string, unknown>;
knownAgentIds: Set<string>;
attemptedAgentIds: Set<string>;
inFlightAgentIds: Set<string>;
}): 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<string, AgentGuidedSetup>,
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" };
@@ -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<T> = (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<void>;
}) => Promise<void>;
createAgent: (name: string, avatarSeed: string | null) => Promise<{ id: string }>;
applySetup: (agentId: string, setup: AgentGuidedSetup) => Promise<void>;
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> | void;
setCreateAgentModalOpen: (open: boolean) => void;
onCompletion: (completion: CreateAgentLifecycleCompletion) => Promise<void> | 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<boolean> => {
@@ -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<string>;
pendingSetupsByAgentId: Record<string, AgentGuidedSetup>;
setRetryBusyAgentId: SetState<string | null>;
applyPendingSetup: (agentId: string) => Promise<{ applied: boolean }>;
removePending: (agentId: string) => void;
isDisconnectLikeError: (error: unknown) => boolean;
resolveAgentName: (agentId: string) => string;
onApplied: () => Promise<void> | void;
onError: (message: string) => void;
}): Promise<boolean> => {
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,
@@ -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<Record<AgentFileName, string>>;
execApprovals:
| {
security: GatewayExecApprovalSecurity;
ask: GatewayExecApprovalAsk;
allowlist: Array<{ pattern: string }>;
}
| null;
};
export const applyGuidedAgentSetup = async (params: {
client: GatewayClient;
agentId: string;
setup: AgentGuidedSetup;
includeAgentOverrides?: boolean;
}): Promise<void> => {
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,
};
};
@@ -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<void>;
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<GuidedCreateWorkflowResult> => {
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<GuidedRetryWorkflowResult> => {
const resolvedAgentId = resolveAgentId(agentId);
const result = await deps.applyPendingSetup(resolvedAgentId);
if (result.applied) {
deps.removePending(resolvedAgentId);
}
return { applied: result.applied };
};
@@ -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<string, AgentGuidedSetup>;
knownAgentIds: Set<string>;
attemptedAgentIds: Set<string>;
inFlightAgentIds: Set<string>;
applyRetry: (agentId: string) => Promise<boolean>;
}): Promise<boolean> => {
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);
};
@@ -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<T> = (next: T | ((current: T) => T)) => void;
export const applyPendingGuidedSetupRetryViaStudio = async (params: {
agentId: string;
source: PendingSetupRetrySource;
retryBusyAgentId: string | null;
inFlightAgentIds: Set<string>;
pendingSetupsByAgentId: Record<string, AgentGuidedSetup>;
setRetryBusyAgentId: SetState<string | null>;
executeRetry: (agentId: string) => Promise<{ applied: boolean }>;
isDisconnectLikeError: (error: unknown) => boolean;
resolveAgentName: (agentId: string) => string;
onApplied: () => Promise<void> | void;
onError: (message: string) => void;
}): Promise<boolean> => {
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
);
}
};
@@ -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> | 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<boolean> => {
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;
}
};
+53 -135
View File
@@ -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");
});
});
-224
View File
@@ -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);
});
});
@@ -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<string, AgentGuidedSetup> = {};
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<string>(),
inFlightAgentIds: new Set<string>(),
});
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<string>(),
});
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 (
@@ -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<string>(),
})
).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<string>(),
})
).toEqual({
kind: "skip",
reason: "no-eligible-agent",
});
});
it("returns timeout intent when mutation block exceeds max wait", () => {
expect(
resolveMutationTimeoutIntent({
@@ -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> = {}
): 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<string>(),
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<string>(),
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);
});
});
-269
View File
@@ -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<string, { security?: string; ask?: string; allowlist?: Array<{ pattern: string }> }>;
};
};
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");
});
});
@@ -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<string, AgentGuidedSetup> = {};
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<string, AgentGuidedSetup> = { "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<string, AgentGuidedSetup> = { "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<string, AgentGuidedSetup> = {};
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<string>();
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);
});
});
-143
View File
@@ -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();
});
});
-213
View File
@@ -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<string, string>();
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");
});
});
@@ -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"
@@ -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<string>();
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")];
@@ -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<string>();
const inFlight = new Set<string>();
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<string>();
const inFlight = new Set<string>();
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);
});
});
@@ -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<string>(),
inFlightAgentIds: new Set<string>(),
};
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<string>();
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);
});
});
@@ -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<T> = (next: T | ((current: T) => T)) => void;
const createState = <T,>(initial: T): { get: () => T; set: SetState<T> } => {
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<string | null>("other");
const inFlight = new Set<string>();
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<string | null>(null);
const inFlight = new Set<string>();
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<string | null>(null);
const setBusy = vi.fn(busy.set);
const inFlight = new Set<string>();
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();
});
});
@@ -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<string, string>();
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();
});
});
-252
View File
@@ -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<string, string>();
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();
});
});
@@ -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();
});
});