mirror of
https://github.com/grp06/openclaw-studio.git
synced 2026-08-14 00:47:51 +00:00
Merge pull request #65 from grp06/codex/simplify-agent-creation-flow
Refine agent creation onboarding and lifecycle handling
This commit is contained in:
+8
-10
@@ -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.
|
||||
|
||||
@@ -7,7 +7,7 @@ This document exists to onboard coding agents quickly when debugging:
|
||||
- How “create agent” choices in **OpenClaw Studio** flow into the **OpenClaw Gateway** (often running on an EC2 host) where enforcement actually happens
|
||||
|
||||
Scope:
|
||||
- Studio guided agent creation (the “authority level” / permissions choices) and the exact gateway calls it makes.
|
||||
- Studio one-step agent creation and post-create authority updates, including exact gateway calls.
|
||||
- The upstream OpenClaw implementation that persists and enforces those settings at runtime.
|
||||
|
||||
Non-scope:
|
||||
@@ -41,83 +41,50 @@ The Gateway (OpenClaw) is the enforcement point:
|
||||
|
||||
## Studio: Where “Permissions” Are Chosen
|
||||
|
||||
Guided creation UI:
|
||||
- `src/features/agents/components/AgentCreateModal.tsx`
|
||||
Agent creation is now create-only:
|
||||
- `src/features/agents/components/AgentCreateModal.tsx` captures `name` and optional avatar shuffle seed.
|
||||
- `src/features/agents/operations/createAgentMutationLifecycleOperation.ts` applies queue/guard behavior and calls create.
|
||||
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) performs `config.get` + `agents.create`.
|
||||
|
||||
Guided draft compilation (UI intent -> concrete gateway setup):
|
||||
- `src/features/agents/creation/compiler.ts` (`compileGuidedAgentCreation`)
|
||||
Authority/permission changes happen after creation from settings:
|
||||
- `src/features/agents/operations/executionRoleUpdateOperation.ts` (`updateExecutionRoleViaStudio`)
|
||||
- updates per-agent exec approvals (`exec.approvals.get` + `exec.approvals.set`)
|
||||
- updates runtime tool overrides (`config.get` + `config.patch` via `updateGatewayAgentOverrides`)
|
||||
- updates session exec behavior (`sessions.patch` via `syncGatewaySessionSettings`)
|
||||
|
||||
Key outputs of compilation:
|
||||
- `files`: content for `IDENTITY.md`, `SOUL.md`, etc (written via gateway `agents.files.set`)
|
||||
- `agentOverrides`:
|
||||
- `sandbox.mode`
|
||||
- `sandbox.workspaceAccess`
|
||||
- `tools.profile`
|
||||
- `tools.alsoAllow`
|
||||
- `tools.deny`
|
||||
- `execApprovals` (when exec/runtime tools are enabled):
|
||||
- `security`: `deny | allowlist | full`
|
||||
- `ask`: `off | on-miss | always`
|
||||
- `allowlist`: patterns
|
||||
### Runtime Tool Groups Used By Post-Create Role Updates
|
||||
|
||||
### Tool Profiles And Tool Groups (What Studio Is Actually Writing)
|
||||
|
||||
Studio’s guided presets use the same tool profiles and tool groups that upstream OpenClaw expands:
|
||||
- Upstream groups + profiles: `openclaw/src/agents/tool-policy.ts`
|
||||
|
||||
Common groups referenced by Studio:
|
||||
- `group:fs`: `read`, `write`, `edit`, `apply_patch`
|
||||
- `group:runtime`: `exec`, `process`
|
||||
- `group:web`: `web_search`, `web_fetch`
|
||||
- `group:sessions`: `sessions_*` tools
|
||||
- `group:memory`: memory tools
|
||||
Studio role updates still rely on OpenClaw tool-group expansion (`openclaw/src/agents/tool-policy.ts`), especially:
|
||||
- `group:runtime` -> runtime execution tools (`exec`, `process`)
|
||||
|
||||
What this means in practice:
|
||||
- If Studio denies `group:runtime`, upstream expands that into denying `exec` and `process`, so command execution becomes unavailable regardless of exec approvals policy.
|
||||
- If Studio denies `write/edit/apply_patch` directly (as it does in “propose-only”), upstream will enforce that at tool selection time even if the sandbox has a writable workspace directory.
|
||||
|
||||
### Studio Gotcha: `sandbox.mode` Is Currently Hardcoded
|
||||
|
||||
In `compileGuidedAgentCreation`, Studio currently sets:
|
||||
- `const normalizedSandboxMode = "off";`
|
||||
|
||||
That means guided creation always writes `sandbox.mode = "off"` for newly created agents.
|
||||
- Conservative role removes runtime group access and sets exec approvals to deny.
|
||||
- Collaborative/autonomous roles include runtime group access and set exec approvals to allowlist/full respectively.
|
||||
|
||||
## Studio -> Gateway: “Create Agent” End-to-End
|
||||
|
||||
Primary entry points:
|
||||
- `src/features/agents/operations/createAgentOperation.ts` (`createAgentWithOptionalSetup`, `applyGuidedAgentSetup`)
|
||||
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`, `updateGatewayAgentOverrides`)
|
||||
- `src/lib/gateway/agentFiles.ts` (writes bootstrap files)
|
||||
- `src/lib/gateway/execApprovals.ts` (writes per-agent exec approvals policy)
|
||||
- `src/features/agents/operations/createAgentMutationLifecycleOperation.ts`
|
||||
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`)
|
||||
|
||||
Sequence (local gateway):
|
||||
Sequence:
|
||||
|
||||
```mermaid
|
||||
sequenceDiagram
|
||||
participant UI as Studio UI
|
||||
participant C as Studio compiler
|
||||
participant L as Create lifecycle
|
||||
participant GC as Studio GatewayClient
|
||||
participant G as OpenClaw Gateway
|
||||
|
||||
UI->>C: compileGuidedAgentCreation(draft)
|
||||
C-->>UI: { files, agentOverrides, execApprovals }
|
||||
|
||||
UI->>GC: createGatewayAgent(name)
|
||||
UI->>L: submit({ name, avatarSeed? })
|
||||
L->>GC: createGatewayAgent(name)
|
||||
GC->>G: config.get
|
||||
G-->>GC: { path: ".../openclaw.json", ... }
|
||||
GC->>G: agents.create({ name, workspace: "<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 Studio’s restart-block workflow runs. (The settings still end up persisted on the gateway host; Studio is just coordinating when to write them.)
|
||||
|
||||
### How Studio Chooses the Default Workspace Path
|
||||
|
||||
Studio computes a default workspace path from the gateway’s config path:
|
||||
@@ -131,17 +98,9 @@ Logic:
|
||||
|
||||
Important: for a remote gateway (EC2), that `workspace` path refers to the gateway host filesystem, not your laptop.
|
||||
|
||||
## Studio: Sandbox Env Allowlist Sync (Why It Exists)
|
||||
## Studio: Sandbox Env Allowlist Sync (Current Scope)
|
||||
|
||||
When guided setup applies, Studio first calls:
|
||||
- `src/lib/gateway/sandboxEnvAllowlist.ts` (`ensureGatewaySandboxEnvAllowlistFromDotEnv`)
|
||||
|
||||
Behavior:
|
||||
- Reads dotenv keys from `/api/gateway/dotenv-keys` (best-effort; if the route is missing it returns).
|
||||
- Patches the gateway config (`config.set`) to ensure `agents.defaults.sandbox.docker.env` includes entries like:
|
||||
- `FOO: "${FOO}"`
|
||||
|
||||
This is a “plumbing” step so sandbox containers can receive expected environment variables without Studio needing to hardcode them into agent templates.
|
||||
Create flow does not perform setup writes during initial create anymore. If Studio needs to ensure sandbox env allowlist entries, that behavior should be attached to explicit settings/config operations rather than create-time side effects.
|
||||
|
||||
## OpenClaw (Upstream): What `agents.create` Actually Does
|
||||
|
||||
@@ -264,17 +223,19 @@ Key enforcement:
|
||||
This is why “`workspaceAccess=ro`” means more than “mount it read-only”:
|
||||
- It is also a tool-policy gate that prevents direct file writes/edits through PI tools.
|
||||
|
||||
### Studio Gotcha: Guided Validation Does Not Match Upstream Enforcement
|
||||
### Studio Note: Authority Is No Longer Compiled During Create
|
||||
|
||||
In `src/features/agents/creation/compiler.ts`, Studio currently validates:
|
||||
- “Auto file edits require sandbox workspace access ro or rw.”
|
||||
Studio create flow no longer compiles authority/sandbox settings during initial create.
|
||||
|
||||
But upstream OpenClaw disables PI write/edit/apply_patch when `workspaceAccess === "ro"`.
|
||||
When authority is changed post-create, Studio uses:
|
||||
- `src/features/agents/operations/executionRoleUpdateOperation.ts`
|
||||
|
||||
So if “auto-edit” is intended to mean “agent can apply edits via tools to the real workspace”, the effective requirement is:
|
||||
- `sandbox.workspaceAccess = "rw"`
|
||||
That operation updates:
|
||||
- exec approvals policy (`exec.approvals.set`)
|
||||
- per-agent tool overrides (`config.patch` via `updateGatewayAgentOverrides`)
|
||||
- session exec host/security/ask (`sessions.patch`)
|
||||
|
||||
If `workspaceAccess = "none"`, upstream may still allow sandboxed write/edit tools, but those edits apply to the sandbox workspace, not the agent workspace (and the agent workspace is not mounted).
|
||||
Upstream enforcement is unchanged: `workspaceAccess="ro"` still disables PI `write`/`edit`/`apply_patch` in sandboxed sessions.
|
||||
|
||||
## Session-Level Exec Settings (Where `exec` Runs)
|
||||
|
||||
|
||||
+5
-239
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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));
|
||||
};
|
||||
@@ -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),
|
||||
};
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
};
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user