diff --git a/desktop/src/features/agents/ui/AgentEditDialog.tsx b/desktop/src/features/agents/ui/AgentEditDialog.tsx index bf8a09888d..74c86392dc 100644 --- a/desktop/src/features/agents/ui/AgentEditDialog.tsx +++ b/desktop/src/features/agents/ui/AgentEditDialog.tsx @@ -47,8 +47,10 @@ import type { } from "@/shared/api/types"; import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent"; import type { AgentDefinitionSubmitOptions } from "./AgentDefinitionDialog"; +import { runLocationForBackend } from "@/features/agents/lib/agentAccessWarning"; import { AgentDefinitionDialog } from "./AgentDefinitionDialog"; import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog"; +import { AgentRunLocationProvider } from "./AgentRunLocationContext"; import { editPersonaDialogState } from "./personaDialogState"; // ── Types ───────────────────────────────────────────────────────────────────── @@ -103,18 +105,26 @@ export function AgentEditDialog({ // parallelism, env vars, harness pin, auto-restart, start-on-launch, instance // name) and owns a well-tested save path. Route all instance-present contexts // here so no I/L field is accidentally omitted. + // + // Wrap in AgentRunLocationProvider so RespondToField's access warning can + // name the machine the agent actually runs on (mirrors AgentDialog's instance- + // edit arm — backend → run location for the remote-backend warning path). if (ctx.kind === "instance-with-definition" || ctx.kind === "instance-only") { return ( - + + + ); } diff --git a/desktop/src/features/agents/ui/agentDialogRouting.test.mjs b/desktop/src/features/agents/ui/agentDialogRouting.test.mjs index 15b6b7cf87..ecf1d68bc7 100644 --- a/desktop/src/features/agents/ui/agentDialogRouting.test.mjs +++ b/desktop/src/features/agents/ui/agentDialogRouting.test.mjs @@ -5,6 +5,7 @@ import { AgentDialog } from "./AgentDialog.tsx"; import { AgentDefinitionDialog } from "./AgentDefinitionDialog.tsx"; import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog.tsx"; import { AgentRunLocationProvider } from "./AgentRunLocationContext.tsx"; +import { AgentEditDialog } from "./AgentEditDialog.tsx"; // ── Phase 1B.3c routing pinning ───────────────────────────────────────────── // @@ -110,3 +111,124 @@ test("create mode routes to the internal create router, not a form directly", () ); assert.equal(element.type.name, "AgentCreateDialogRouter"); }); + +// ── Phase 1 merged surface routing (AgentEditDialog) ──────────────────────── +// +// AgentEditDialog collapses R1–R7 onto one entry point. Like AgentDialog, the +// instance-present arms are hook-free so they can be called directly and the +// returned element tree inspected. The definition-only arm delegates to a +// sub-component that owns hooks, so we only confirm the type name there. +// +// These tests prove: +// • instance-with-definition → AgentInstanceEditDialog (all I/L fields live here) +// • instance-only → AgentInstanceEditDialog (unlinked agents save correctly) +// • definition-only → AgentEditDefinitionOnlyDialog sub-component +// +// They also confirm the AgentRunLocationProvider wrapper is present on instance +// paths (so RespondToField's remote-backend warning works). + +const minimalDefinition = { + id: "def-abc", + displayName: "Bot", + avatarUrl: "", + systemPrompt: "Do stuff.", + runtime: "goose", + model: null, + provider: null, + isBuiltIn: false, + isActive: true, + namePool: [], + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + createdAt: "2025-01-01T00:00:00Z", + updatedAt: "2025-01-01T00:00:00Z", +}; + +const minimalInstance = { + pubkey: "pk-0000", + name: "Bot", + avatarUrl: "", + systemPrompt: "Do stuff.", + model: null, + provider: null, + envVars: {}, + respondTo: null, + respondToAllowlist: [], + parallelism: null, + autoRestartOnConfigChange: false, + startOnAppLaunch: false, +}; + +test("AgentEditDialog: instance-with-definition routes instance to AgentInstanceEditDialog", () => { + const ctx = { + kind: "instance-with-definition", + definition: minimalDefinition, + instance: minimalInstance, + }; + + const element = AgentEditDialog({ + ctx, + open: true, + onOpenChange: noop, + }); + + // Wrapped in AgentRunLocationProvider for respondTo warning. + assert.equal(element.type, AgentRunLocationProvider); + const form = element.props.children; + assert.equal( + form.type, + AgentInstanceEditDialog, + "linked agent edit must reach AgentInstanceEditDialog (all I/L fields)", + ); + assert.equal(form.props.agent, minimalInstance); + assert.equal(form.props.open, true); + assert.equal( + form.props.onEditLinkedPersona, + undefined, + "R4 back-door must be deleted", + ); +}); + +test("AgentEditDialog: instance-only routes to AgentInstanceEditDialog (unlinked save)", () => { + const ctx = { kind: "instance-only", instance: minimalInstance }; + + const element = AgentEditDialog({ + ctx, + open: true, + onOpenChange: noop, + }); + + // Wrapped in AgentRunLocationProvider for respondTo warning. + assert.equal(element.type, AgentRunLocationProvider); + const form = element.props.children; + assert.equal( + form.type, + AgentInstanceEditDialog, + "unlinked agent edit must reach AgentInstanceEditDialog (UpdateManagedAgentInput path)", + ); + assert.equal(form.props.agent, minimalInstance); +}); + +test("AgentEditDialog: definition-only routes to definition sub-component", () => { + const ctx = { kind: "definition-only", definition: minimalDefinition }; + + const element = AgentEditDialog({ + ctx, + open: true, + onOpenChange: noop, + }); + + // Hook-bearing sub-component: confirm by name rather than reference. + assert.equal( + typeof element.type, + "function", + "definition-only must route to a React component", + ); + assert.equal( + element.type.name, + "AgentEditDefinitionOnlyDialog", + "definition-only must use the coordinator-wired sub-component", + ); +});