Polish agent inspect UX and approval resume behavior

This commit is contained in:
George Pickett
2026-02-20 20:35:50 -08:00
parent db4d488f58
commit 8a73cd4ded
10 changed files with 899 additions and 923 deletions
+23 -20
View File
@@ -41,31 +41,39 @@ The Gateway (OpenClaw) is the enforcement point:
## Studio: Where “Permissions” Are Chosen
Agent creation is now create-only:
Agent creation is intentionally lightweight:
- `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/features/agents/operations/mutationLifecycleWorkflow.ts` applies queue/guard behavior and calls create.
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`) performs `config.get` + `agents.create`.
Authority/permission changes happen after creation from settings:
After creation, Studio applies a safe default capability envelope:
- Commands: `Ask`
- Web access: `Off`
- File tools: `Off`
Implementation:
- `src/app/page.tsx` (`handleCreateAgentSubmit`) applies `CREATE_AGENT_DEFAULT_PERMISSIONS`.
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`) persists those defaults.
Further capability changes happen from the `Capabilities` tab:
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`)
- updates per-agent exec approvals (`exec.approvals.get` + `exec.approvals.set`)
- updates tool-group overrides for runtime, web, and file access (`config.get` + `config.patch` via `updateGatewayAgentOverrides`)
- updates session exec behavior (`sessions.patch` via `syncGatewaySessionSettings`)
- `src/features/agents/operations/executionRoleUpdateOperation.ts` remains for execution-role-only updates and bootstrap paths.
### Runtime Tool Groups Used By Post-Create Role Updates
### Runtime Tool Groups Used By Capability Updates
Studio role updates still rely on OpenClaw tool-group expansion (`openclaw/src/agents/tool-policy.ts`), especially:
Studio capability updates 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:
- 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.
Internal mapping detail:
- Command mode `off|ask|auto` maps to role logic (`conservative|collaborative|autonomous`) for policy generation.
- UI exposes direct capability controls, not role labels.
## Studio -> Gateway: “Create Agent” End-to-End
Primary entry points:
- `src/features/agents/operations/createAgentMutationLifecycleOperation.ts`
- `src/features/agents/operations/mutationLifecycleWorkflow.ts`
- `src/lib/gateway/agentConfig.ts` (`createGatewayAgent`)
Sequence:
@@ -228,8 +236,8 @@ This is why “`workspaceAccess=ro`” means more than “mount it read-only”:
Studio create flow no longer compiles authority/sandbox settings during initial create.
When authority is changed post-create, Studio uses:
- `src/features/agents/operations/executionRoleUpdateOperation.ts`
When capabilities are changed post-create, Studio uses:
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`)
That operation updates:
- exec approvals policy (`exec.approvals.set`)
@@ -312,7 +320,7 @@ Studio wiring for UX:
Studio can also change permissions after an agent exists.
### Capabilities Permissions Updates (Preset + Advanced)
### Capabilities Permissions Updates
Studios permissions flow applies coordinated changes from one save action:
- Exec approvals policy (per-agent, persisted in exec approvals file)
@@ -323,13 +331,8 @@ Code:
- `src/features/agents/operations/agentPermissionsOperation.ts` (`updateAgentPermissionsViaStudio`)
UI model:
- Presets: `Conservative`, `Collaborative`, `Autonomous`
- Advanced controls: `Command mode` (`Off`/`Ask`/`Auto`), `Web access` (`Off`/`On`), `File tools` (`Off`/`On`)
- Create modal remains permission-free; permissions are configured only after creation.
Terminology note:
- Current builds may still label this surface as `Settings`.
- Planned IA renames this to `Capabilities` and splits `Schedule` and `Advanced` into separate surfaces.
- Direct controls: `Command mode` (`Off`/`Ask`/`Auto`), `Web access` (`Off`/`On`), `File tools` (`Off`/`On`)
- Create modal remains permission-light (name/avatar only) and create flow immediately applies safe defaults (`Ask`, web off, file tools off).
Why it matters:
- You can have exec approvals configured but still be unable to run commands if `group:runtime` is denied.
+47 -48
View File
@@ -1,58 +1,57 @@
# Studio UI Guide
This doc covers Studio UI behavior and near-term UX direction.
This doc describes the current Studio IA and behavior.
## Current Behavior (Today)
## Agent Surfaces
### Agent creation workflow
- Click **New Agent** in the fleet sidebar.
- Enter an agent name and avatar, then create.
- The create modal does not include permission controls.
- After create succeeds, Studio currently opens **Settings** for the new agent.
### Chat (default)
- Selecting an agent opens chat as the primary workspace.
- Chat header controls include:
- New session
- Personality shortcut
- Settings shortcut
- New session resets the current agent session and clears visible transcript state in Studio.
### Cron jobs
- Open an agent and go to **Settings -> Cron jobs**.
- If no jobs exist, use the empty-state **Create** button.
- If jobs already exist, use the header **Create** button.
- The modal is agent-scoped and walks through template selection, task text, schedule, and review.
- Submitting creates the job via gateway `cron.add` and refreshes that same agent's cron list.
### Settings Sidebar
- The settings cog opens one sidebar with four tabs:
1. Personality
2. Capabilities
3. Automations
4. Advanced
### Exec approvals in chat
- When a run requires exec approval, chat shows an **Exec approval required** card with command preview, host/cwd, and expiration.
- Resolve directly in chat with:
- **Allow once**
- **Always allow**
- **Deny**
- The fleet row displays **Needs approval** while approvals are pending for that agent.
## Personality
- Personality is the first tab when opening settings.
- Rename agent lives in Personality.
- Personality file tabs are intentionally limited to:
- Personality (`SOUL.md`)
- Instructions (`AGENTS.md`)
- About You (`USER.md`)
- Identity (`IDENTITY.md`)
- Underlying persistence still saves the full gateway-backed agent file set.
## Direction (Planned)
## Capabilities
- Capabilities exposes direct controls (no role preset labels):
- Run commands: Off / Ask / Auto
- Web access: Off / On
- File tools: Off / On
- Skills and Browser automation are visible as coming-soon toggles.
Studio is moving to a capability-first IA for non-technical users.
## Automations
- User-facing language is schedules/automations (not cron-first terminology).
- Schedule creation uses template -> task -> schedule -> review flow.
- Heartbeats are represented in this tab as coming soon.
### Per-agent surfaces
1. `Chat` (default)
2. `Personality`
3. `Capabilities`
4. `Schedule`
5. `Advanced`
## Advanced
- Advanced contains:
- Display toggles (Show tool calls, Show thinking)
- Open Full Control UI
- Delete agent (danger zone)
- Session controls are not in Advanced.
### Naming and scope changes
- `Brain` is being renamed to `Personality`.
- `Settings` is being split into `Capabilities`, `Schedule`, and `Advanced`.
- `Delete agent` moves to `Advanced` danger zone only.
- `New session` moves to a chat-adjacent header action.
- Personality editor should use friendly labels over raw filenames:
- `Instructions` for `AGENTS.md`
- `About You` for `USER.md`
- `Personality` context for `SOUL.md` + `IDENTITY.md`
### Capability-first model (planned)
- Commands: `Off` / `Ask before running` / `Run automatically`
- Web research: `Off` / `On`
- Browser automation: `Off` / `On`
- Files: `Off` / `Read-only` / `Read & write`
- Automations: managed in `Schedule`
- Integrations: skill enable/disable and API key setup
### Defaults direction
- New agents should move toward a collaborative/safe default envelope rather than autonomous-by-default behavior.
## Agent Creation Defaults
- Create modal captures only name/avatar.
- After creation, Studio applies safe defaults:
- Commands: Ask
- Web access: Off
- File tools: Off
- Post-create UX keeps chat as primary and auto-opens Capabilities sidebar for onboarding.
+261 -291
View File
@@ -55,13 +55,9 @@ import type { CronCreateDraft } from "@/lib/cron/createPayloadBuilder";
import {
createGatewayAgent,
renameGatewayAgent,
removeGatewayHeartbeatOverride,
listHeartbeatsForAgent,
readConfigAgentList,
slugifyAgentName,
triggerHeartbeatNow,
updateGatewayAgentOverrides,
type AgentHeartbeatSummary,
} from "@/lib/gateway/agentConfig";
import { buildAvatarDataUrl } from "@/lib/avatars/multiavatar";
import { createStudioSettingsCoordinator } from "@/lib/studio/coordinator";
@@ -107,7 +103,6 @@ import {
} from "@/features/agents/operations/latestUpdateWorkflow";
import { createSpecialLatestUpdateOperation } from "@/features/agents/operations/specialLatestUpdateOperation";
import {
updateExecutionRoleViaStudio,
updateAgentPermissionsViaStudio,
resolveAgentPermissionsDraft,
type AgentPermissionsDraft,
@@ -138,12 +133,78 @@ import {
const DEFAULT_CHAT_HISTORY_LIMIT = 200;
const MAX_CHAT_HISTORY_LIMIT = 5000;
const PENDING_EXEC_APPROVAL_PRUNE_GRACE_MS = 500;
const EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS = 3_000;
const CREATE_AGENT_DEFAULT_PERMISSIONS: AgentPermissionsDraft = {
commandMode: "ask",
webAccess: false,
fileTools: false,
};
type MobilePane = "fleet" | "chat" | "settings" | "brain";
type MobilePane = "fleet" | "chat" | "settings";
type InspectSidebarTab = "personality" | "capabilities" | "automations" | "advanced";
type InspectSidebarState = { agentId: string; tab: InspectSidebarTab } | null;
type RestartingMutationBlockState = MutationBlockState & { kind: MutationWorkflowKind };
const RESERVED_MAIN_AGENT_ID = "main";
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const normalizeControlUiBasePath = (basePath: string): string => {
let normalized = basePath.trim();
if (!normalized || normalized === "/") return "";
if (!normalized.startsWith("/")) {
normalized = `/${normalized}`;
}
if (normalized.endsWith("/")) {
normalized = normalized.slice(0, -1);
}
return normalized;
};
const resolveControlUiUrl = (params: {
gatewayUrl: string;
configSnapshot: GatewayModelPolicySnapshot | null;
}): string | null => {
const rawGatewayUrl = params.gatewayUrl.trim();
if (!rawGatewayUrl) return null;
let controlUiEnabled = true;
let controlUiBasePath = "";
const config = params.configSnapshot?.config;
if (isRecord(config)) {
const configRecord = config as Record<string, unknown>;
const gateway = isRecord(configRecord["gateway"])
? (configRecord["gateway"] as Record<string, unknown>)
: null;
const controlUi = gateway && isRecord(gateway.controlUi) ? gateway.controlUi : null;
if (controlUi && typeof controlUi.enabled === "boolean") {
controlUiEnabled = controlUi.enabled;
}
if (typeof controlUi?.basePath === "string") {
controlUiBasePath = normalizeControlUiBasePath(controlUi.basePath);
}
}
if (!controlUiEnabled) return null;
try {
const url = new URL(rawGatewayUrl);
if (url.protocol === "ws:") {
url.protocol = "http:";
} else if (url.protocol === "wss:") {
url.protocol = "https:";
}
url.pathname = controlUiBasePath ? `${controlUiBasePath}/` : "/";
url.search = "";
url.hash = "";
return url.toString();
} catch {
return null;
}
};
const resolveNextNewAgentName = (agents: AgentState[]) => {
const baseName = "New Agent";
const existingNames = new Set(
@@ -200,19 +261,13 @@ const AgentStudioPage = () => {
const [createAgentModalError, setCreateAgentModalError] = useState<string | null>(null);
const [stopBusyAgentId, setStopBusyAgentId] = useState<string | null>(null);
const [mobilePane, setMobilePane] = useState<MobilePane>("chat");
const [settingsAgentId, setSettingsAgentId] = useState<string | null>(null);
const [inspectSidebar, setInspectSidebar] = useState<InspectSidebarState>(null);
const [settingsCronJobs, setSettingsCronJobs] = useState<CronJobSummary[]>([]);
const [settingsCronLoading, setSettingsCronLoading] = useState(false);
const [settingsCronError, setSettingsCronError] = useState<string | null>(null);
const [cronCreateBusy, setCronCreateBusy] = useState(false);
const [cronRunBusyJobId, setCronRunBusyJobId] = useState<string | null>(null);
const [cronDeleteBusyJobId, setCronDeleteBusyJobId] = useState<string | null>(null);
const [settingsHeartbeats, setSettingsHeartbeats] = useState<AgentHeartbeatSummary[]>([]);
const [settingsHeartbeatLoading, setSettingsHeartbeatLoading] = useState(false);
const [settingsHeartbeatError, setSettingsHeartbeatError] = useState<string | null>(null);
const [heartbeatRunBusyId, setHeartbeatRunBusyId] = useState<string | null>(null);
const [heartbeatDeleteBusyId, setHeartbeatDeleteBusyId] = useState<string | null>(null);
const [brainPanelOpen, setBrainPanelOpen] = useState(false);
const [createAgentBlock, setCreateAgentBlock] = useState<CreateAgentBlockState | null>(null);
const [restartingMutationBlock, setRestartingMutationBlock] =
useState<RestartingMutationBlockState | null>(null);
@@ -258,12 +313,14 @@ const AgentStudioPage = () => {
if (!lastMessage || !isHeartbeatPrompt(lastMessage)) return null;
return "This task is running as an automatic heartbeat check. Stopping heartbeat runs from Studio isn't available yet (coming soon).";
}, [focusedAgent]);
const settingsAgent = useMemo(() => {
if (!settingsAgentId) return null;
return agents.find((entry) => entry.agentId === settingsAgentId) ?? null;
}, [agents, settingsAgentId]);
const inspectSidebarAgentId = inspectSidebar?.agentId ?? null;
const inspectSidebarTab = inspectSidebar?.tab ?? null;
const inspectSidebarAgent = useMemo(() => {
if (!inspectSidebarAgentId) return null;
return agents.find((entry) => entry.agentId === inspectSidebarAgentId) ?? null;
}, [agents, inspectSidebarAgentId]);
const settingsAgentPermissionsDraft = useMemo(() => {
if (!settingsAgent) return null;
if (!inspectSidebarAgent) return null;
const baseConfig =
gatewayConfigSnapshot?.config &&
typeof gatewayConfigSnapshot.config === "object" &&
@@ -271,7 +328,7 @@ const AgentStudioPage = () => {
? (gatewayConfigSnapshot.config as Record<string, unknown>)
: undefined;
const list = readConfigAgentList(baseConfig);
const configEntry = list.find((entry) => entry.id === settingsAgent.agentId) ?? null;
const configEntry = list.find((entry) => entry.id === inspectSidebarAgent.agentId) ?? null;
const toolsRaw =
configEntry && typeof (configEntry as Record<string, unknown>).tools === "object"
? ((configEntry as Record<string, unknown>).tools as unknown)
@@ -281,13 +338,10 @@ const AgentStudioPage = () => {
? (toolsRaw as Record<string, unknown>)
: null;
return resolveAgentPermissionsDraft({
agent: settingsAgent,
agent: inspectSidebarAgent,
existingTools: tools,
});
}, [gatewayConfigSnapshot, settingsAgent]);
const selectedBrainAgentId = useMemo(() => {
return focusedAgent?.agentId ?? agents[0]?.agentId ?? null;
}, [agents, focusedAgent]);
}, [gatewayConfigSnapshot, inspectSidebarAgent]);
const focusedPendingExecApprovals = useMemo(() => {
if (!focusedAgentId) return unscopedPendingExecApprovals;
const scoped = pendingExecApprovalsByAgentId[focusedAgentId] ?? [];
@@ -319,6 +373,10 @@ const AgentStudioPage = () => {
);
const hasRunningAgents = runningAgentCount > 0;
const isLocalGateway = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]);
const controlUiUrl = useMemo(
() => resolveControlUiUrl({ gatewayUrl, configSnapshot: gatewayConfigSnapshot }),
[gatewayConfigSnapshot, gatewayUrl]
);
const hasRenameMutationBlock = restartingMutationBlock?.kind === "rename-agent";
const hasDeleteMutationBlock = restartingMutationBlock?.kind === "delete-agent";
@@ -462,7 +520,7 @@ const AgentStudioPage = () => {
const resolvedAgentId = agentId.trim();
if (!resolvedAgentId) {
setSettingsCronJobs([]);
setSettingsCronError("Failed to load cron jobs: missing agent id.");
setSettingsCronError("Failed to load schedules: missing agent id.");
return;
}
setSettingsCronLoading(true);
@@ -472,7 +530,7 @@ const AgentStudioPage = () => {
const filtered = filterCronJobsForAgent(result.jobs, resolvedAgentId);
setSettingsCronJobs(sortCronJobsByUpdatedAt(filtered));
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load cron jobs.";
const message = err instanceof Error ? err.message : "Failed to load schedules.";
setSettingsCronJobs([]);
setSettingsCronError(message);
if (!isGatewayDisconnectLikeError(err)) {
@@ -485,33 +543,6 @@ const AgentStudioPage = () => {
[client]
);
const loadHeartbeatsForSettingsAgent = useCallback(
async (agentId: string) => {
const resolvedAgentId = agentId.trim();
if (!resolvedAgentId) {
setSettingsHeartbeats([]);
setSettingsHeartbeatError("Failed to load heartbeats: missing agent id.");
return;
}
setSettingsHeartbeatLoading(true);
setSettingsHeartbeatError(null);
try {
const result = await listHeartbeatsForAgent(client, resolvedAgentId);
setSettingsHeartbeats(result.heartbeats);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to load heartbeats.";
setSettingsHeartbeats([]);
setSettingsHeartbeatError(message);
if (!isGatewayDisconnectLikeError(err)) {
console.error(message);
}
} finally {
setSettingsHeartbeatLoading(false);
}
},
[client]
);
const refreshHeartbeatLatestUpdate = useCallback(() => {
const agents = stateRef.current.agents;
specialLatestUpdate.refreshHeartbeat(agents);
@@ -790,41 +821,46 @@ const AgentStudioPage = () => {
useEffect(() => {
if (!settingsAgentId) return;
if (state.selectedAgentId && state.selectedAgentId !== settingsAgentId) {
setSettingsAgentId(null);
if (!inspectSidebar) return;
const selectedAgentId = state.selectedAgentId?.trim() ?? "";
if (!selectedAgentId) {
setInspectSidebar(null);
return;
}
}, [settingsAgentId, state.selectedAgentId]);
if (inspectSidebar.agentId === selectedAgentId) return;
setInspectSidebar((current) =>
current ? { ...current, agentId: selectedAgentId } : current
);
}, [inspectSidebar, state.selectedAgentId]);
useEffect(() => {
if (settingsAgentId && !settingsAgent) {
setSettingsAgentId(null);
if (inspectSidebarAgentId && !inspectSidebarAgent) {
setInspectSidebar(null);
}
}, [settingsAgentId, settingsAgent]);
}, [inspectSidebarAgent, inspectSidebarAgentId]);
useEffect(() => {
if (!settingsAgentId) return;
if (!inspectSidebarAgentId) return;
if (status !== "connected") return;
void refreshGatewayConfigSnapshot();
}, [refreshGatewayConfigSnapshot, settingsAgentId, status]);
}, [refreshGatewayConfigSnapshot, inspectSidebarAgentId, status]);
useEffect(() => {
if (!settingsAgentId || status !== "connected") {
if (!inspectSidebarAgentId || status !== "connected" || inspectSidebarTab !== "automations") {
setSettingsCronJobs([]);
setSettingsCronLoading(false);
setSettingsCronError(null);
setCronRunBusyJobId(null);
setCronDeleteBusyJobId(null);
setSettingsHeartbeats([]);
setSettingsHeartbeatLoading(false);
setSettingsHeartbeatError(null);
setHeartbeatRunBusyId(null);
setHeartbeatDeleteBusyId(null);
return;
}
void loadCronJobsForSettingsAgent(settingsAgentId);
void loadHeartbeatsForSettingsAgent(settingsAgentId);
}, [loadCronJobsForSettingsAgent, loadHeartbeatsForSettingsAgent, settingsAgentId, status]);
void loadCronJobsForSettingsAgent(inspectSidebarAgentId);
}, [
inspectSidebarAgentId,
inspectSidebarTab,
loadCronJobsForSettingsAgent,
status,
]);
useEffect(() => {
const nowMs = Date.now();
@@ -872,23 +908,11 @@ const AgentStudioPage = () => {
}
}, [agents, dispatch, pendingExecApprovalsByAgentId]);
useEffect(() => {
if (!brainPanelOpen) return;
if (selectedBrainAgentId) return;
setBrainPanelOpen(false);
}, [brainPanelOpen, selectedBrainAgentId]);
useEffect(() => {
if (mobilePane !== "settings") return;
if (settingsAgent) return;
if (inspectSidebarAgent) return;
setMobilePane("chat");
}, [mobilePane, settingsAgent]);
useEffect(() => {
if (mobilePane !== "brain") return;
if (brainPanelOpen && selectedBrainAgentId) return;
setMobilePane("chat");
}, [brainPanelOpen, mobilePane, selectedBrainAgentId]);
}, [inspectSidebarAgent, mobilePane]);
useEffect(() => {
if (status !== "connected") {
@@ -1098,26 +1122,21 @@ const AgentStudioPage = () => {
}
}, [agents, loadAgentHistory, status]);
const handleOpenAgentSettings = useCallback(
(agentId: string) => {
const handleOpenAgentInspectSidebar = useCallback(
(agentId: string, tab: InspectSidebarTab) => {
flushPendingDraft(focusedAgent?.agentId ?? null);
setBrainPanelOpen(false);
setSettingsAgentId(agentId);
setInspectSidebar({ agentId, tab });
setMobilePane("settings");
dispatch({ type: "selectAgent", agentId });
},
[dispatch, flushPendingDraft, focusedAgent]
);
const handleOpenAgentBrain = useCallback(
const handleOpenAgentPersonality = useCallback(
(agentId: string) => {
flushPendingDraft(focusedAgent?.agentId ?? null);
setSettingsAgentId(null);
setBrainPanelOpen(true);
setMobilePane("brain");
dispatch({ type: "selectAgent", agentId });
handleOpenAgentInspectSidebar(agentId, "personality");
},
[dispatch, flushPendingDraft, focusedAgent]
[handleOpenAgentInspectSidebar]
);
const runRestartingMutationLifecycle = useCallback(
@@ -1219,7 +1238,7 @@ const AgentStudioPage = () => {
const agent = agents.find((entry) => entry.agentId === agentId);
if (!agent) return;
const confirmed = window.confirm(
`Delete ${agent.name}? This removes the agent from gateway config + cron and moves its workspace/state into ~/.openclaw/trash on the gateway host.`
`Delete ${agent.name}? This removes the agent from gateway config + scheduled automations and moves its workspace/state into ~/.openclaw/trash on the gateway host.`
);
if (!confirmed) return;
await runRestartingMutationLifecycle({
@@ -1234,7 +1253,7 @@ const AgentStudioPage = () => {
fetchJson,
logError: (message, error) => console.error(message, error),
});
setSettingsAgentId(null);
setInspectSidebar(null);
},
});
},
@@ -1266,7 +1285,7 @@ const AgentStudioPage = () => {
onJobs: setSettingsCronJobs,
});
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to create cron job.";
const message = err instanceof Error ? err.message : "Failed to create automation.";
if (!isGatewayDisconnectLikeError(err)) {
console.error(message);
}
@@ -1288,7 +1307,7 @@ const AgentStudioPage = () => {
await runCronJobNow(client, resolvedJobId);
await loadCronJobsForSettingsAgent(resolvedAgentId);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to run cron job.";
const message = err instanceof Error ? err.message : "Failed to run schedule.";
setSettingsCronError(message);
console.error(message);
} finally {
@@ -1313,7 +1332,7 @@ const AgentStudioPage = () => {
}
await loadCronJobsForSettingsAgent(resolvedAgentId);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to delete cron job.";
const message = err instanceof Error ? err.message : "Failed to delete schedule.";
setSettingsCronError(message);
console.error(message);
} finally {
@@ -1323,60 +1342,6 @@ const AgentStudioPage = () => {
[client, cronCreateBusy, cronDeleteBusyJobId, cronRunBusyJobId, loadCronJobsForSettingsAgent]
);
const handleRunHeartbeat = useCallback(
async (agentId: string, heartbeatId: string) => {
const resolvedAgentId = agentId.trim();
const resolvedHeartbeatId = heartbeatId.trim();
if (!resolvedAgentId || !resolvedHeartbeatId) return;
if (heartbeatRunBusyId || heartbeatDeleteBusyId) return;
setHeartbeatRunBusyId(resolvedHeartbeatId);
setSettingsHeartbeatError(null);
try {
await triggerHeartbeatNow(client, resolvedAgentId);
await loadHeartbeatsForSettingsAgent(resolvedAgentId);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to trigger heartbeat.";
setSettingsHeartbeatError(message);
console.error(message);
} finally {
setHeartbeatRunBusyId((current) =>
current === resolvedHeartbeatId ? null : current
);
}
},
[client, heartbeatDeleteBusyId, heartbeatRunBusyId, loadHeartbeatsForSettingsAgent]
);
const handleDeleteHeartbeat = useCallback(
async (agentId: string, heartbeatId: string) => {
const resolvedAgentId = agentId.trim();
const resolvedHeartbeatId = heartbeatId.trim();
if (!resolvedAgentId || !resolvedHeartbeatId) return;
if (heartbeatRunBusyId || heartbeatDeleteBusyId) return;
setHeartbeatDeleteBusyId(resolvedHeartbeatId);
setSettingsHeartbeatError(null);
try {
await removeGatewayHeartbeatOverride({
client,
agentId: resolvedAgentId,
});
setSettingsHeartbeats((heartbeats) =>
heartbeats.filter((heartbeat) => heartbeat.id !== resolvedHeartbeatId)
);
await loadHeartbeatsForSettingsAgent(resolvedAgentId);
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to delete heartbeat.";
setSettingsHeartbeatError(message);
console.error(message);
} finally {
setHeartbeatDeleteBusyId((current) =>
current === resolvedHeartbeatId ? null : current
);
}
},
[client, heartbeatDeleteBusyId, heartbeatRunBusyId, loadHeartbeatsForSettingsAgent]
);
const handleOpenCreateAgentModal = useCallback(() => {
if (createAgentBusy) return;
if (createAgentBlock) return;
@@ -1468,32 +1433,29 @@ const AgentStudioPage = () => {
}
let bootstrapError: string | null = null;
if (
createdAgent.sessionExecSecurity !== "full" ||
createdAgent.sessionExecAsk !== "off"
) {
try {
await updateExecutionRoleViaStudio({
client,
agentId: createdAgent.agentId,
sessionKey: createdAgent.sessionKey,
role: "autonomous",
loadAgents: async () => {},
});
await loadAgents();
await refreshGatewayConfigSnapshot();
} catch (err) {
bootstrapError =
err instanceof Error ? err.message : "Failed to apply autonomous defaults.";
setError(
`Agent created, but autonomous defaults could not be applied: ${bootstrapError}`
);
}
try {
await updateAgentPermissionsViaStudio({
client,
agentId: createdAgent.agentId,
sessionKey: createdAgent.sessionKey,
draft: CREATE_AGENT_DEFAULT_PERMISSIONS,
loadAgents,
});
await refreshGatewayConfigSnapshot();
} catch (err) {
bootstrapError =
err instanceof Error ? err.message : "Failed to apply default permissions.";
setError(
`Agent created, but default permissions could not be applied: ${bootstrapError}`
);
}
handleOpenAgentSettings(completion.agentId);
flushPendingDraft(focusedAgent?.agentId ?? null);
dispatch({ type: "selectAgent", agentId: completion.agentId });
setInspectSidebar({ agentId: completion.agentId, tab: "capabilities" });
setMobilePane("chat");
if (bootstrapError) {
setCreateAgentModalError(`Autonomous defaults failed: ${bootstrapError}`);
setCreateAgentModalError(`Default permissions failed: ${bootstrapError}`);
} else {
setCreateAgentModalError(null);
}
@@ -1519,7 +1481,6 @@ const AgentStudioPage = () => {
focusedAgent,
hasDeleteMutationBlock,
hasRenameMutationBlock,
handleOpenAgentSettings,
loadAgents,
persistAvatarSeed,
refreshGatewayConfigSnapshot,
@@ -1609,7 +1570,7 @@ const AgentStudioPage = () => {
agentId,
patch,
});
setSettingsAgentId(null);
setInspectSidebar(null);
setMobilePane("chat");
} catch (err) {
const message = err instanceof Error ? err.message : "Failed to start new session.";
@@ -1772,37 +1733,6 @@ const AgentStudioPage = () => {
setPendingExecApprovalsByAgentId,
setUnscopedPendingExecApprovals,
requestHistoryRefresh: (agentId) => loadAgentHistory(agentId),
onAllowResolved: ({ approval, targetAgentId }) => {
const scopedPending = (pendingExecApprovalsByAgentId[targetAgentId] ?? []).some(
(pendingApproval) => pendingApproval.id !== approval.id
);
const targetSessionKey = approval.sessionKey?.trim() ?? "";
const unscopedPending = unscopedPendingExecApprovals.some((pendingApproval) => {
if (pendingApproval.id === approval.id) return false;
const pendingAgentId = pendingApproval.agentId?.trim() ?? "";
if (pendingAgentId && pendingAgentId === targetAgentId) return true;
if (!targetSessionKey) return false;
return (pendingApproval.sessionKey?.trim() ?? "") === targetSessionKey;
});
if (scopedPending || unscopedPending) return;
const latest =
stateRef.current.agents.find((entry) => entry.agentId === targetAgentId) ?? null;
if (!latest) return;
const pausedRunId =
approvalPausedRunIdByAgentRef.current.get(targetAgentId)?.trim() ?? "";
const nowMs = Date.now();
dispatch({
type: "updateAgent",
agentId: targetAgentId,
patch: {
status: "running",
sessionCreated: true,
lastActivityAt: nowMs,
...(pausedRunId ? { runId: pausedRunId } : {}),
...(latest.runStartedAt === null ? { runStartedAt: nowMs } : {}),
},
});
},
onAllowed: async ({ approval, targetAgentId }) => {
const pausedByAgent = approvalPausedRunIdByAgentRef.current;
const pausedRunId = pausedByAgent.get(targetAgentId) ?? null;
@@ -1824,8 +1754,18 @@ const AgentStudioPage = () => {
}
pausedByAgent.delete(targetAgentId);
const nowMs = Date.now();
dispatch({
type: "updateAgent",
agentId: targetAgentId,
patch: {
status: "running",
runId: pausedRunId,
lastActivityAt: nowMs,
},
});
try {
await client.call("agent.wait", { runId: pausedRunId, timeoutMs: 15_000 });
await client.call("agent.wait", { runId: pausedRunId, timeoutMs: EXEC_APPROVAL_AUTO_RESUME_WAIT_TIMEOUT_MS });
} catch (waitError) {
if (!isGatewayDisconnectLikeError(waitError)) {
console.warn("Failed waiting for paused run before auto-resume.", waitError);
@@ -1833,7 +1773,11 @@ const AgentStudioPage = () => {
}
const latest = stateRef.current.agents.find((entry) => entry.agentId === targetAgentId) ?? null;
if (!latest || latest.status === "running") return;
if (!latest) return;
const latestRunId = latest.runId?.trim() ?? "";
if (latest.status === "running" && latestRunId && latestRunId !== pausedRunId) {
return;
}
const sessionKey = latest.sessionKey.trim();
if (!sessionKey) return;
@@ -2124,7 +2068,10 @@ const AgentStudioPage = () => {
});
await loadAgents();
await refreshGatewayConfigSnapshot();
setSettingsAgentId(resolvedAgentId);
setInspectSidebar((current) => {
if (current?.agentId === resolvedAgentId) return current;
return { agentId: resolvedAgentId, tab: "capabilities" };
});
setMobilePane("settings");
},
});
@@ -2342,7 +2289,7 @@ const AgentStudioPage = () => {
<div className="flex min-h-0 flex-1 flex-col gap-4 xl:flex-row">
<div className="glass-panel ui-panel p-2 xl:hidden" data-testid="mobile-pane-toggle">
<div className="ui-segment grid-cols-4">
<div className="ui-segment grid-cols-3">
<button
type="button"
className="ui-segment-item px-2 py-2 font-mono text-[12px] font-medium tracking-[0.02em]"
@@ -2363,24 +2310,14 @@ const AgentStudioPage = () => {
type="button"
className="ui-segment-item px-2 py-2 font-mono text-[12px] font-medium tracking-[0.02em]"
data-active={mobilePane === "settings" ? "true" : "false"}
onClick={() => setMobilePane("settings")}
disabled={!settingsAgent}
onClick={() => {
if (!focusedAgent) return;
handleOpenAgentPersonality(focusedAgent.agentId);
}}
disabled={!focusedAgent}
>
Settings
</button>
<button
type="button"
className="ui-segment-item px-2 py-2 font-mono text-[12px] font-medium tracking-[0.02em]"
data-active={mobilePane === "brain" ? "true" : "false"}
onClick={() => {
setBrainPanelOpen(true);
setSettingsAgentId(null);
setMobilePane("brain");
}}
disabled={!hasAnyAgents}
>
Brain
</button>
</div>
</div>
<div
@@ -2399,6 +2336,9 @@ const AgentStudioPage = () => {
onSelectAgent={(agentId) => {
flushPendingDraft(focusedAgent?.agentId ?? null);
dispatch({ type: "selectAgent", agentId });
setInspectSidebar((current) =>
current ? { ...current, agentId } : current
);
setMobilePane("chat");
}}
/>
@@ -2418,8 +2358,8 @@ const AgentStudioPage = () => {
stopBusy={stopBusyAgentId === focusedAgent.agentId}
stopDisabledReason={focusedAgentStopDisabledReason}
onLoadMoreHistory={() => loadMoreAgentHistory(focusedAgent.agentId)}
onOpenSettings={() => handleOpenAgentSettings(focusedAgent.agentId)}
onOpenBrain={() => handleOpenAgentBrain(focusedAgent.agentId)}
onOpenSettings={() => handleOpenAgentPersonality(focusedAgent.agentId)}
onNewSession={() => handleNewSession(focusedAgent.agentId)}
onModelChange={(value) =>
handleModelChange(focusedAgent.agentId, focusedAgent.sessionKey, value)
}
@@ -2454,67 +2394,97 @@ const AgentStudioPage = () => {
/>
)}
</div>
{brainPanelOpen ? (
{inspectSidebarAgent ? (
<div
className={`${mobilePane === "brain" ? "block" : "hidden"} sidebar-shell glass-panel ui-panel ui-depth-sidepanel min-h-0 w-full shrink-0 overflow-hidden p-0 xl:block xl:min-w-[360px] xl:max-w-[430px]`}
className={`${mobilePane === "settings" ? "block" : "hidden"} sidebar-shell glass-panel ui-panel ui-depth-sidepanel flex min-h-0 w-full shrink-0 flex-col overflow-hidden p-0 xl:flex xl:min-w-[468px] xl:max-w-[559px]`}
>
<AgentBrainPanel
client={client}
agents={agents}
selectedAgentId={selectedBrainAgentId}
onClose={() => {
setBrainPanelOpen(false);
setMobilePane("chat");
}}
/>
</div>
) : null}
{settingsAgent ? (
<div
className={`${mobilePane === "settings" ? "block" : "hidden"} sidebar-shell glass-panel ui-panel ui-depth-sidepanel min-h-0 w-full shrink-0 overflow-hidden p-0 xl:block xl:min-w-[360px] xl:max-w-[430px]`}
>
<AgentSettingsPanel
key={settingsAgent.agentId}
agent={settingsAgent}
onClose={() => {
setSettingsAgentId(null);
setMobilePane("chat");
}}
onRename={(name) => handleRenameAgent(settingsAgent.agentId, name)}
permissionsDraft={settingsAgentPermissionsDraft ?? undefined}
onUpdateAgentPermissions={(draft) =>
handleUpdateAgentPermissions(settingsAgent.agentId, draft)
}
onNewSession={() => handleNewSession(settingsAgent.agentId)}
onDelete={() => handleDeleteAgent(settingsAgent.agentId)}
canDelete={settingsAgent.agentId !== RESERVED_MAIN_AGENT_ID}
onToolCallingToggle={(enabled) =>
handleToolCallingToggle(settingsAgent.agentId, enabled)
}
onThinkingTracesToggle={(enabled) =>
handleThinkingTracesToggle(settingsAgent.agentId, enabled)
}
cronJobs={settingsCronJobs}
cronLoading={settingsCronLoading}
cronError={settingsCronError}
cronCreateBusy={cronCreateBusy}
cronRunBusyJobId={cronRunBusyJobId}
cronDeleteBusyJobId={cronDeleteBusyJobId}
onCreateCronJob={(draft) => handleCreateCronJob(settingsAgent.agentId, draft)}
onRunCronJob={(jobId) => handleRunCronJob(settingsAgent.agentId, jobId)}
onDeleteCronJob={(jobId) => handleDeleteCronJob(settingsAgent.agentId, jobId)}
heartbeats={settingsHeartbeats}
heartbeatLoading={settingsHeartbeatLoading}
heartbeatError={settingsHeartbeatError}
heartbeatRunBusyId={heartbeatRunBusyId}
heartbeatDeleteBusyId={heartbeatDeleteBusyId}
onRunHeartbeat={(heartbeatId) =>
handleRunHeartbeat(settingsAgent.agentId, heartbeatId)
}
onDeleteHeartbeat={(heartbeatId) =>
handleDeleteHeartbeat(settingsAgent.agentId, heartbeatId)
}
/>
<div className="border-b border-border/60 px-3 py-3">
<div className="ui-segment grid-cols-4">
{(
[
{ id: "personality", label: "Personality" },
{ id: "capabilities", label: "Capabilities" },
{ id: "automations", label: "Automations" },
{ id: "advanced", label: "Advanced" },
] as const
).map((entry) => {
const active = inspectSidebarTab === entry.id;
return (
<button
key={entry.id}
type="button"
className="ui-segment-item px-2 py-2 font-mono text-[11px] font-medium tracking-[0.02em]"
data-active={active ? "true" : "false"}
onClick={() =>
setInspectSidebar((current) => {
if (!current) {
return { agentId: inspectSidebarAgent.agentId, tab: entry.id };
}
if (current.tab === entry.id) return current;
return { ...current, tab: entry.id };
})
}
>
{entry.label}
</button>
);
})}
</div>
</div>
<div className="min-h-0 flex-1">
{inspectSidebarTab === "personality" ? (
<AgentBrainPanel
client={client}
agents={agents}
selectedAgentId={inspectSidebarAgent.agentId}
onRename={(name) => handleRenameAgent(inspectSidebarAgent.agentId, name)}
onClose={() => {
setInspectSidebar(null);
setMobilePane("chat");
}}
/>
) : (
<AgentSettingsPanel
key={`${inspectSidebarAgent.agentId}:${inspectSidebarTab ?? "capabilities"}`}
mode={
inspectSidebarTab === "automations"
? "automations"
: inspectSidebarTab === "advanced"
? "advanced"
: "capabilities"
}
agent={inspectSidebarAgent}
onClose={() => {
setInspectSidebar(null);
setMobilePane("chat");
}}
permissionsDraft={settingsAgentPermissionsDraft ?? undefined}
onUpdateAgentPermissions={(draft) =>
handleUpdateAgentPermissions(inspectSidebarAgent.agentId, draft)
}
onDelete={() => handleDeleteAgent(inspectSidebarAgent.agentId)}
canDelete={inspectSidebarAgent.agentId !== RESERVED_MAIN_AGENT_ID}
onToolCallingToggle={(enabled) =>
handleToolCallingToggle(inspectSidebarAgent.agentId, enabled)
}
onThinkingTracesToggle={(enabled) =>
handleThinkingTracesToggle(inspectSidebarAgent.agentId, enabled)
}
cronJobs={settingsCronJobs}
cronLoading={settingsCronLoading}
cronError={settingsCronError}
cronCreateBusy={cronCreateBusy}
cronRunBusyJobId={cronRunBusyJobId}
cronDeleteBusyJobId={cronDeleteBusyJobId}
onCreateCronJob={(draft) => handleCreateCronJob(inspectSidebarAgent.agentId, draft)}
onRunCronJob={(jobId) => handleRunCronJob(inspectSidebarAgent.agentId, jobId)}
onDeleteCronJob={(jobId) =>
handleDeleteCronJob(inspectSidebarAgent.agentId, jobId)
}
controlUiUrl={controlUiUrl}
/>
)}
</div>
</div>
) : null}
</div>
@@ -12,7 +12,7 @@ import {
import type { AgentState as AgentRecord } from "@/features/agents/state/store";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import { Brain, ChevronRight, Clock, Cog, Shuffle } from "lucide-react";
import { ChevronRight, Clock, Cog, Shuffle } from "lucide-react";
import type { GatewayModelChoice } from "@/lib/gateway/models";
import { rewriteMediaLinesToMarkdown } from "@/lib/text/media-markdown";
import { normalizeAssistantDisplayText } from "@/lib/text/assistantText";
@@ -98,7 +98,7 @@ type AgentChatPanelProps = {
stopDisabledReason?: string | null;
onLoadMoreHistory: () => void;
onOpenSettings: () => void;
onOpenBrain?: () => void;
onNewSession?: () => Promise<void> | void;
onModelChange: (value: string | null) => void;
onThinkingChange: (value: string | null) => void;
onDraftChange: (value: string) => void;
@@ -401,7 +401,7 @@ const AssistantMessageCard = memo(function AssistantMessageCard({
</div>
) : (
<div className="mt-2 space-y-3 dark:space-y-5">
{streaming ? (
{streaming && !hasThinking ? (
<div
className="flex items-center gap-2 text-[10px] text-muted-foreground/80"
role="status"
@@ -832,7 +832,7 @@ export const AgentChatPanel = ({
stopDisabledReason = null,
onLoadMoreHistory,
onOpenSettings,
onOpenBrain,
onNewSession,
onModelChange,
onThinkingChange,
onDraftChange,
@@ -843,6 +843,7 @@ export const AgentChatPanel = ({
onResolveExecApproval,
}: AgentChatPanelProps) => {
const [draftValue, setDraftValue] = useState(agent.draft);
const [newSessionBusy, setNewSessionBusy] = useState(false);
const draftRef = useRef<HTMLTextAreaElement | null>(null);
const scrollToBottomNextOutputRef = useRef(false);
const plainDraftRef = useRef(agent.draft);
@@ -875,7 +876,6 @@ export const AgentChatPanel = ({
sessionKey: agent.sessionKey,
};
plainDraftRef.current = agent.draft;
// eslint-disable-next-line react-hooks/set-state-in-effect
setDraftValue(agent.draft);
return;
}
@@ -928,12 +928,19 @@ export const AgentChatPanel = ({
[agent.outputLines, agent.showThinkingTraces, agent.toolCallingEnabled]
);
const running = agent.status === "running";
const renderBlocks = useMemo(() => buildAgentChatRenderBlocks(chatItems), [chatItems]);
const hasActiveStreamingTailInTranscript =
running && renderBlocks.length > 0 && !renderBlocks[renderBlocks.length - 1].text;
const liveAssistantText =
running && agent.streamText ? normalizeAssistantDisplayText(agent.streamText) : "";
const liveThinkingText =
running && agent.showThinkingTraces && agent.thinkingTrace ? agent.thinkingTrace.trim() : "";
const hasVisibleLiveThinking = Boolean(liveThinkingText.trim());
const showTypingIndicator = running && !hasVisibleLiveThinking;
const showTypingIndicator =
running &&
!hasVisibleLiveThinking &&
!liveAssistantText &&
!hasActiveStreamingTailInTranscript;
const modelOptions = useMemo(
() =>
@@ -984,6 +991,18 @@ export const AgentChatPanel = ({
handleSend(draftValue);
}, [draftValue, handleSend]);
const handleNewSession = useCallback(async () => {
if (!onNewSession || newSessionBusy || !canSend) return;
setNewSessionBusy(true);
try {
await onNewSession();
} finally {
setNewSessionBusy(false);
}
}, [canSend, newSessionBusy, onNewSession]);
const newSessionDisabled = newSessionBusy || !canSend || !onNewSession;
return (
<div data-agent-panel className="group fade-up relative flex h-full w-full flex-col">
<div className="px-3 pt-3 sm:px-4 sm:pt-4">
@@ -1080,21 +1099,24 @@ export const AgentChatPanel = ({
<div className="mt-0.5 flex items-center gap-2">
<button
className="nodrag ui-btn-icon"
className="nodrag ui-btn-primary px-3 py-2 font-mono text-[12px] font-medium tracking-[0.02em] disabled:cursor-not-allowed disabled:border-border disabled:bg-muted disabled:text-muted-foreground"
type="button"
data-testid="agent-brain-toggle"
aria-label="Open agent brain files"
title="Brain files"
onClick={() => onOpenBrain?.()}
data-testid="agent-new-session-toggle"
aria-label="Start new session"
title="Start new session"
onClick={() => {
void handleNewSession();
}}
disabled={newSessionDisabled}
>
<Brain className="h-4 w-4" />
{newSessionBusy ? "Starting..." : "New session"}
</button>
<button
className="nodrag ui-btn-icon"
type="button"
data-testid="agent-settings-toggle"
aria-label="Open agent settings"
title="Agent settings"
aria-label="Open personality"
title="Personality"
onClick={onOpenSettings}
>
<Cog className="h-4 w-4" />
File diff suppressed because it is too large Load Diff
+16
View File
@@ -10,6 +10,22 @@ export const AGENT_FILE_NAMES = [
export type AgentFileName = (typeof AGENT_FILE_NAMES)[number];
export const PERSONALITY_FILE_NAMES = [
"SOUL.md",
"AGENTS.md",
"USER.md",
"IDENTITY.md",
] as const satisfies readonly AgentFileName[];
export type PersonalityFileName = (typeof PERSONALITY_FILE_NAMES)[number];
export const PERSONALITY_FILE_LABELS: Record<PersonalityFileName, string> = {
"SOUL.md": "Personality",
"AGENTS.md": "Instructions",
"USER.md": "About You",
"IDENTITY.md": "Identity",
};
export const isAgentFileName = (value: string): value is AgentFileName =>
AGENT_FILE_NAMES.includes(value as AgentFileName);
+46 -4
View File
@@ -107,15 +107,23 @@ describe("AgentBrainPanel", () => {
client,
agents,
selectedAgentId: "agent-1",
onRename: vi.fn(async () => true),
onClose: vi.fn(),
})
);
await waitFor(() => {
expect(screen.getByRole("button", { name: "Agents" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Instructions" })).toBeInTheDocument();
});
expect(screen.getByText("alpha agents")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Personality" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "About You" })).toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Tools" })).not.toBeInTheDocument();
expect(screen.getByText("Be useful.")).toBeInTheDocument();
fireEvent.click(screen.getByRole("button", { name: "Instructions" }));
await waitFor(() => {
expect(screen.getByText("alpha agents")).toBeInTheDocument();
});
fireEvent.click(screen.getByRole("button", { name: "Identity" }));
await waitFor(() => {
expect(screen.getByText("Name: Alpha")).toBeInTheDocument();
@@ -131,6 +139,7 @@ describe("AgentBrainPanel", () => {
client,
agents,
selectedAgentId: "",
onRename: vi.fn(async () => true),
onClose: vi.fn(),
})
);
@@ -150,6 +159,7 @@ describe("AgentBrainPanel", () => {
client,
agents,
selectedAgentId: "agent-1",
onRename: vi.fn(async () => true),
onClose,
})
);
@@ -159,14 +169,21 @@ describe("AgentBrainPanel", () => {
});
fireEvent.click(screen.getByRole("button", { name: "Identity" }));
fireEvent.click(screen.getByRole("button", { name: "Edit" }));
const textarea = await screen.findByRole("textbox");
const textarea = await waitFor(() => {
const panel = screen.getByTestId("agent-personality-files");
const element = panel.querySelector("textarea");
if (!element) {
throw new Error("Personality editor textarea not found.");
}
return element;
});
fireEvent.change(textarea, {
target: {
value:
"# IDENTITY.md - Who Am I?\n\n- Name: Alpha Prime\n- Creature: droid\n- Vibe: calm\n- Emoji: 🤖\n",
},
});
fireEvent.click(screen.getByTestId("agent-brain-close"));
fireEvent.click(screen.getByTestId("agent-personality-close"));
await waitFor(() => {
expect(onClose).toHaveBeenCalledTimes(1);
@@ -187,4 +204,29 @@ describe("AgentBrainPanel", () => {
String((identityWrite?.params as Record<string, unknown>).content ?? "")
).toContain("- Name: Alpha Prime");
});
it("renames_agent_from_personality_panel", async () => {
const { client } = createMockClient();
const agents = [createAgent("agent-1", "Alpha", "session-1")];
const onRename = vi.fn(async () => true);
render(
createElement(AgentBrainPanel, {
client,
agents,
selectedAgentId: "agent-1",
onRename,
onClose: vi.fn(),
})
);
fireEvent.change(screen.getByLabelText("Agent name"), {
target: { value: " Alpha Prime " },
});
fireEvent.click(screen.getByRole("button", { name: "Update Name" }));
await waitFor(() => {
expect(onRename).toHaveBeenCalledWith("Alpha Prime");
});
});
});
+11 -10
View File
@@ -73,15 +73,15 @@ describe("AgentChatPanel controls", () => {
expect(screen.getByText("Model")).toBeInTheDocument();
expect(screen.getByText("Thinking")).toBeInTheDocument();
expect(screen.queryByDisplayValue("Agent One")).not.toBeInTheDocument();
expect(screen.getByTestId("agent-brain-toggle")).toBeInTheDocument();
expect(screen.getByLabelText("Open agent brain files")).toBeInTheDocument();
expect(screen.getByTestId("agent-new-session-toggle")).toBeInTheDocument();
expect(screen.getByLabelText("Start new session")).toBeInTheDocument();
expect(screen.getByTestId("agent-settings-toggle")).toBeInTheDocument();
expect(screen.getByLabelText("Open agent settings")).toBeInTheDocument();
expect(screen.getByLabelText("Open personality")).toBeInTheDocument();
expect(screen.queryByText("Inspect")).not.toBeInTheDocument();
});
it("invokes_on_open_brain_when_control_clicked", () => {
const onOpenBrain = vi.fn();
it("invokes_on_new_session_when_control_clicked", () => {
const onNewSession = vi.fn(async () => {});
render(
createElement(AgentChatPanel, {
@@ -92,7 +92,7 @@ describe("AgentChatPanel controls", () => {
stopBusy: false,
onLoadMoreHistory: vi.fn(),
onOpenSettings: vi.fn(),
onOpenBrain,
onNewSession,
onModelChange: vi.fn(),
onThinkingChange: vi.fn(),
onDraftChange: vi.fn(),
@@ -102,8 +102,8 @@ describe("AgentChatPanel controls", () => {
})
);
fireEvent.click(screen.getByTestId("agent-brain-toggle"));
expect(onOpenBrain).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getByTestId("agent-new-session-toggle"));
expect(onNewSession).toHaveBeenCalledTimes(1);
});
it("renders semantic status badge markers for idle and running states", () => {
@@ -334,7 +334,7 @@ describe("AgentChatPanel controls", () => {
expect(within(screen.getByTestId("agent-typing-indicator")).getByText("Thinking")).toBeInTheDocument();
});
it("keeps_thinking_animation_visible_when_saved_thinking_exists", () => {
it("does_not_render_duplicate_typing_indicator_when_internal_thinking_is_visible", () => {
render(
createElement(AgentChatPanel, {
agent: {
@@ -357,7 +357,8 @@ describe("AgentChatPanel controls", () => {
})
);
expect(screen.getAllByTestId("agent-typing-indicator").length).toBeGreaterThan(0);
expect(screen.queryByTestId("agent-typing-indicator")).not.toBeInTheDocument();
expect(screen.getByText("Thinking (internal)")).toBeInTheDocument();
});
it("renders thinking row collapsed by default", () => {
+2 -4
View File
@@ -44,13 +44,11 @@ describe("AgentSettingsPanel header", () => {
cleanup();
});
it("uses inspect header style with section label and agent title", () => {
it("uses inspect header style with agent title", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -64,7 +62,7 @@ describe("AgentSettingsPanel header", () => {
})
);
expect(screen.getByText("Agent settings")).toBeInTheDocument();
expect(screen.queryByText("Capabilities")).not.toBeInTheDocument();
expect(screen.getByText("Web Researcher")).toBeInTheDocument();
expect(screen.getByLabelText("Close panel")).toBeInTheDocument();
});
+95 -152
View File
@@ -4,7 +4,6 @@ import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/re
import type { AgentState } from "@/features/agents/state/store";
import { AgentSettingsPanel } from "@/features/agents/components/AgentInspectPanels";
import type { CronJobSummary } from "@/lib/cron/types";
import type { AgentHeartbeatSummary } from "@/lib/gateway/agentConfig";
const createAgent = (): AgentState => ({
agentId: "agent-1",
@@ -54,35 +53,16 @@ const createCronJob = (id: string): CronJobSummary => ({
state: {},
});
const createHeartbeat = (
source: AgentHeartbeatSummary["source"] = "override"
): AgentHeartbeatSummary => ({
id: "agent-1",
agentId: "agent-1",
source,
enabled: true,
heartbeat: {
every: "30m",
target: "last",
includeReasoning: false,
ackMaxChars: 300,
activeHours: null,
},
});
describe("AgentSettingsPanel", () => {
afterEach(() => {
cleanup();
});
it("renders_identity_rename_section_and_saves_trimmed_name", async () => {
const onRename = vi.fn(async () => true);
it("does_not_render_name_editor_in_capabilities_mode", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
onClose: vi.fn(),
onRename,
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -96,14 +76,8 @@ describe("AgentSettingsPanel", () => {
})
);
fireEvent.change(screen.getByLabelText("Agent name"), {
target: { value: " Agent Two " },
});
fireEvent.click(screen.getByRole("button", { name: "Update Name" }));
await waitFor(() => {
expect(onRename).toHaveBeenCalledWith("Agent Two");
});
expect(screen.queryByLabelText("Agent name")).not.toBeInTheDocument();
expect(screen.queryByRole("button", { name: "Update Name" })).not.toBeInTheDocument();
});
it("renders_icon_close_button_with_accessible_label", () => {
@@ -111,8 +85,6 @@ describe("AgentSettingsPanel", () => {
createElement(AgentSettingsPanel, {
agent: createAgent(),
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -134,9 +106,8 @@ describe("AgentSettingsPanel", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "advanced",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -163,8 +134,6 @@ describe("AgentSettingsPanel", () => {
createElement(AgentSettingsPanel, {
agent: createAgent(),
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -178,7 +147,7 @@ describe("AgentSettingsPanel", () => {
})
);
expect(screen.getByText("Permissions")).toBeInTheDocument();
expect(screen.queryByText("Capabilities")).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Run commands off" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Run commands ask" })).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Run commands auto" })).toBeInTheDocument();
@@ -197,8 +166,6 @@ describe("AgentSettingsPanel", () => {
createElement(AgentSettingsPanel, {
agent: createAgent(),
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -218,49 +185,44 @@ describe("AgentSettingsPanel", () => {
});
it("autosaves_updated_permissions_draft", async () => {
vi.useFakeTimers();
try {
const onUpdateAgentPermissions = vi.fn(async () => {});
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
permissionsDraft: {
commandMode: "off",
webAccess: false,
fileTools: false,
},
onUpdateAgentPermissions,
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
cronRunBusyJobId: null,
cronDeleteBusyJobId: null,
onRunCronJob: vi.fn(),
onDeleteCronJob: vi.fn(),
})
);
const onUpdateAgentPermissions = vi.fn(async () => {});
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
permissionsDraft: {
commandMode: "off",
webAccess: false,
fileTools: false,
},
onUpdateAgentPermissions,
onClose: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
cronJobs: [],
cronLoading: false,
cronError: null,
cronRunBusyJobId: null,
cronDeleteBusyJobId: null,
onRunCronJob: vi.fn(),
onDeleteCronJob: vi.fn(),
})
);
fireEvent.click(screen.getByRole("button", { name: "Run commands auto" }));
fireEvent.click(screen.getByRole("switch", { name: "Web access" }));
fireEvent.click(screen.getByRole("switch", { name: "File tools" }));
await vi.advanceTimersByTimeAsync(500);
fireEvent.click(screen.getByRole("button", { name: "Run commands auto" }));
fireEvent.click(screen.getByRole("switch", { name: "Web access" }));
fireEvent.click(screen.getByRole("switch", { name: "File tools" }));
await waitFor(() => {
await waitFor(
() => {
expect(onUpdateAgentPermissions).toHaveBeenCalledWith({
commandMode: "auto",
webAccess: true,
fileTools: true,
});
});
} finally {
vi.useRealTimers();
}
},
{ timeout: 2000 }
);
});
it("does_not_render_runtime_settings_section", () => {
@@ -268,8 +230,6 @@ describe("AgentSettingsPanel", () => {
createElement(AgentSettingsPanel, {
agent: createAgent(),
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -284,17 +244,15 @@ describe("AgentSettingsPanel", () => {
);
expect(screen.queryByText("Runtime settings")).not.toBeInTheDocument();
expect(screen.queryByText("Brain files")).not.toBeInTheDocument();
expect(screen.queryByText("Personality")).not.toBeInTheDocument();
});
it("invokes_on_new_session_when_clicked", () => {
const onNewSession = vi.fn();
it("does_not_render_new_session_control_in_advanced_mode", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "advanced",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession,
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -308,17 +266,15 @@ describe("AgentSettingsPanel", () => {
})
);
fireEvent.click(screen.getByRole("button", { name: "New session" }));
expect(onNewSession).toHaveBeenCalledTimes(1);
expect(screen.queryByRole("button", { name: "New session" })).not.toBeInTheDocument();
});
it("renders_cron_jobs_section_below_session", () => {
it("renders_automations_section_when_mode_is_automations", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -332,11 +288,9 @@ describe("AgentSettingsPanel", () => {
})
);
const sessionSection = screen.getByTestId("agent-settings-session");
const cronSection = screen.getByTestId("agent-settings-cron");
expect(cronSection).toBeInTheDocument();
const position = sessionSection.compareDocumentPosition(cronSection);
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.queryByTestId("agent-settings-session")).not.toBeInTheDocument();
});
it("invokes_run_now_and_disables_play_while_pending", () => {
@@ -345,9 +299,8 @@ describe("AgentSettingsPanel", () => {
const { rerender } = render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -361,15 +314,14 @@ describe("AgentSettingsPanel", () => {
})
);
fireEvent.click(screen.getByRole("button", { name: "Run cron job Job job-1 now" }));
fireEvent.click(screen.getByRole("button", { name: "Run timed automation Job job-1 now" }));
expect(onRunCronJob).toHaveBeenCalledWith("job-1");
rerender(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -383,7 +335,7 @@ describe("AgentSettingsPanel", () => {
})
);
expect(screen.getByRole("button", { name: "Run cron job Job job-1 now" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Run timed automation Job job-1 now" })).toBeDisabled();
});
it("invokes_delete_and_disables_trash_while_pending", () => {
@@ -392,9 +344,8 @@ describe("AgentSettingsPanel", () => {
const { rerender } = render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -408,15 +359,14 @@ describe("AgentSettingsPanel", () => {
})
);
fireEvent.click(screen.getByRole("button", { name: "Delete cron job Job job-1" }));
fireEvent.click(screen.getByRole("button", { name: "Delete timed automation Job job-1" }));
expect(onDeleteCronJob).toHaveBeenCalledWith("job-1");
rerender(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -430,16 +380,15 @@ describe("AgentSettingsPanel", () => {
})
);
expect(screen.getByRole("button", { name: "Delete cron job Job job-1" })).toBeDisabled();
expect(screen.getByRole("button", { name: "Delete timed automation Job job-1" })).toBeDisabled();
});
it("shows_empty_cron_state_when_agent_has_no_jobs", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -453,7 +402,7 @@ describe("AgentSettingsPanel", () => {
})
);
expect(screen.getByText("No cron jobs for this agent.")).toBeInTheDocument();
expect(screen.getByText("No timed automations for this agent.")).toBeInTheDocument();
expect(screen.getByTestId("cron-empty-icon")).toBeInTheDocument();
});
@@ -461,9 +410,8 @@ describe("AgentSettingsPanel", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -484,9 +432,8 @@ describe("AgentSettingsPanel", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -501,16 +448,15 @@ describe("AgentSettingsPanel", () => {
);
fireEvent.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByRole("dialog", { name: "Create cron job" })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "Create automation" })).toBeInTheDocument();
});
it("updates_template_defaults_when_switching_templates", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -527,12 +473,12 @@ describe("AgentSettingsPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "Create" }));
fireEvent.click(screen.getByRole("button", { name: "Weekly Review" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByLabelText("Job name")).toHaveValue("Weekly review");
expect(screen.getByLabelText("Automation name")).toHaveValue("Weekly review");
fireEvent.click(screen.getByRole("button", { name: "Back" }));
fireEvent.click(screen.getByRole("button", { name: "Morning Brief" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
expect(screen.getByLabelText("Job name")).toHaveValue("Morning brief");
expect(screen.getByLabelText("Automation name")).toHaveValue("Morning brief");
});
it("submits_modal_with_agent_scoped_draft", async () => {
@@ -540,9 +486,8 @@ describe("AgentSettingsPanel", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -560,15 +505,21 @@ describe("AgentSettingsPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "Create" }));
fireEvent.click(screen.getByRole("button", { name: "Custom" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.change(screen.getByLabelText("Job name"), {
fireEvent.change(screen.getByLabelText("Automation name"), {
target: { value: "Nightly sync" },
});
fireEvent.change(screen.getByLabelText("Task"), {
target: { value: "Sync project status and report blockers." },
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Next" })).not.toBeDisabled();
});
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Create cron job" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create automation" })).not.toBeDisabled();
});
fireEvent.click(screen.getByRole("button", { name: "Create automation" }));
await waitFor(() => {
expect(onCreateCronJob).toHaveBeenCalledWith({
@@ -584,13 +535,12 @@ describe("AgentSettingsPanel", () => {
});
});
it("disables_create_submit_while_create_in_flight", () => {
it("hides_create_submit_before_review_step_and_disables_next_when_busy", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -606,7 +556,8 @@ describe("AgentSettingsPanel", () => {
);
fireEvent.click(screen.getByRole("button", { name: "Create" }));
expect(screen.getByRole("button", { name: "Create cron job" })).toBeDisabled();
expect(screen.queryByRole("button", { name: "Create automation" })).not.toBeInTheDocument();
expect(screen.getByRole("button", { name: "Next" })).toBeDisabled();
});
it("keeps_modal_open_and_shows_error_when_create_fails", async () => {
@@ -616,9 +567,8 @@ describe("AgentSettingsPanel", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -636,29 +586,34 @@ describe("AgentSettingsPanel", () => {
fireEvent.click(screen.getByRole("button", { name: "Create" }));
fireEvent.click(screen.getByRole("button", { name: "Custom" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.change(screen.getByLabelText("Job name"), {
fireEvent.change(screen.getByLabelText("Automation name"), {
target: { value: "Nightly sync" },
});
fireEvent.change(screen.getByLabelText("Task"), {
target: { value: "Sync project status and report blockers." },
});
await waitFor(() => {
expect(screen.getByRole("button", { name: "Next" })).not.toBeDisabled();
});
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Next" }));
fireEvent.click(screen.getByRole("button", { name: "Create cron job" }));
await waitFor(() => {
expect(screen.getByRole("button", { name: "Create automation" })).not.toBeDisabled();
});
fireEvent.click(screen.getByRole("button", { name: "Create automation" }));
await waitFor(() => {
expect(screen.getByText("Gateway exploded")).toBeInTheDocument();
});
expect(screen.getByRole("dialog", { name: "Create cron job" })).toBeInTheDocument();
expect(screen.getByRole("dialog", { name: "Create automation" })).toBeInTheDocument();
});
it("renders_heartbeat_section_below_cron", () => {
it("shows_heartbeat_coming_soon_in_automations_mode", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "automations",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -669,25 +624,19 @@ describe("AgentSettingsPanel", () => {
cronDeleteBusyJobId: null,
onRunCronJob: vi.fn(),
onDeleteCronJob: vi.fn(),
heartbeats: [createHeartbeat()],
})
);
const cronSection = screen.getByTestId("agent-settings-cron");
const heartbeatSection = screen.getByTestId("agent-settings-heartbeat");
expect(heartbeatSection).toBeInTheDocument();
const position = cronSection.compareDocumentPosition(heartbeatSection);
expect(position & Node.DOCUMENT_POSITION_FOLLOWING).toBeTruthy();
expect(screen.getByTestId("agent-settings-heartbeat-coming-soon")).toBeInTheDocument();
expect(screen.getByText("Heartbeat automation controls are coming soon.")).toBeInTheDocument();
});
it("invokes_run_heartbeat_and_disables_delete_for_inherited", () => {
const onRunHeartbeat = vi.fn();
it("shows_control_ui_section_in_advanced_mode", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "advanced",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -698,24 +647,19 @@ describe("AgentSettingsPanel", () => {
cronDeleteBusyJobId: null,
onRunCronJob: vi.fn(),
onDeleteCronJob: vi.fn(),
heartbeats: [createHeartbeat("default")],
onRunHeartbeat,
})
);
fireEvent.click(screen.getByRole("button", { name: "Run heartbeat for agent-1 now" }));
expect(onRunHeartbeat).toHaveBeenCalledWith("agent-1");
expect(screen.getByRole("button", { name: "Delete heartbeat for agent-1" })).toBeDisabled();
expect(screen.getByTestId("agent-settings-control-ui")).toBeInTheDocument();
expect(screen.getByRole("button", { name: "Open Full Control UI" })).toBeDisabled();
});
it("invokes_delete_heartbeat_for_override", () => {
const onDeleteHeartbeat = vi.fn();
it("renders_enabled_control_ui_link_when_available", () => {
render(
createElement(AgentSettingsPanel, {
agent: createAgent(),
mode: "advanced",
onClose: vi.fn(),
onRename: vi.fn(async () => true),
onNewSession: vi.fn(),
onDelete: vi.fn(),
onToolCallingToggle: vi.fn(),
onThinkingTracesToggle: vi.fn(),
@@ -726,12 +670,11 @@ describe("AgentSettingsPanel", () => {
cronDeleteBusyJobId: null,
onRunCronJob: vi.fn(),
onDeleteCronJob: vi.fn(),
heartbeats: [createHeartbeat("override")],
onDeleteHeartbeat,
controlUiUrl: "http://localhost:3000/control",
})
);
fireEvent.click(screen.getByRole("button", { name: "Delete heartbeat for agent-1" }));
expect(onDeleteHeartbeat).toHaveBeenCalledWith("agent-1");
const link = screen.getByRole("link", { name: "Open Full Control UI" });
expect(link).toHaveAttribute("href", "http://localhost:3000/control");
});
});