mirror of
https://github.com/block/buzz.git
synced 2026-08-18 06:50:31 +02:00
fix(desktop): always start agents and hide unavailable mesh (#1860)
Signed-off-by: Will Pfleger <pfleger.will@gmail.com> Co-authored-by: npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7 <dcfd242e557282d7a1e2cf2e6877522682f1e5c6156dc92ca7d90eaedd3b0f95@sprout-oss.stage.blox.sqprod.co>
This commit is contained in:
co-authored by
npub1mn7jgtj4w2pd0g0zeuhxsa6jy6p0rewxz4kujt98my82ahfmp72sxjexk7
parent
ab5cbc3692
commit
37bc962cb8
@@ -85,8 +85,6 @@ type AgentDefinitionDialogProps = {
|
||||
onSubmit: (
|
||||
input: CreatePersonaInput | UpdatePersonaInput,
|
||||
) => Promise<unknown>;
|
||||
/** Rendered in the footer’s left slot. */
|
||||
createFooterSlot?: React.ReactNode;
|
||||
/** Rendered below the form fields in create mode only ("Where to run"). */
|
||||
createRunSection?: React.ReactNode;
|
||||
/** Extra create-mode submit gate (e.g. incomplete provider config). */
|
||||
@@ -115,7 +113,6 @@ export function AgentDefinitionDialog({
|
||||
runtimesLoading = false,
|
||||
onOpenChange,
|
||||
onSubmit,
|
||||
createFooterSlot,
|
||||
createRunSection,
|
||||
createSubmitBlocked = false,
|
||||
createRunOnMesh = false,
|
||||
@@ -661,7 +658,7 @@ export function AgentDefinitionDialog({
|
||||
title={title}
|
||||
footer={
|
||||
<div className="flex w-full items-center justify-between gap-3">
|
||||
<div className="flex min-h-9 items-center">{createFooterSlot}</div>
|
||||
<div className="flex min-h-9 items-center" />
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
|
||||
@@ -6,14 +6,10 @@ import type {
|
||||
ManagedAgent,
|
||||
UpdatePersonaInput,
|
||||
} from "@/shared/api/types";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
import type { BackendIntent } from "../lib/instanceInputForDefinition";
|
||||
import {
|
||||
definitionCreateDialogState,
|
||||
intentForStartToggle,
|
||||
type AgentCreateIntent,
|
||||
} from "./agentCreateIntent";
|
||||
import type { AgentCreateIntent } from "./agentCreateIntent";
|
||||
import type { EditAgentFocusTarget } from "@/features/agents/openEditAgentEvent";
|
||||
import { useMeshAvailability } from "@/features/mesh-compute/hooks/useMeshAvailability";
|
||||
import { AgentInstanceEditDialog } from "./AgentInstanceEditDialog";
|
||||
import { createPersonaDialogState } from "./personaDialogState";
|
||||
import { AgentDefinitionDialog } from "./AgentDefinitionDialog";
|
||||
@@ -79,10 +75,11 @@ type AgentDialogProps =
|
||||
/**
|
||||
* Unified entry point (Phase 1B.2/1B.3b/1B.3c): routes an intent to the form
|
||||
* that owns it. The definition family renders AgentDefinitionDialog — create
|
||||
* mode adds a "start after create" toggle, definition-edit passes the caller's
|
||||
* PersonaDialogState-derived props through unchanged (edit/duplicate/import).
|
||||
* instance-edit renders AgentInstanceEditDialog (persistent mount + `open`
|
||||
* toggle — its reset lifecycle is keyed on [open, agent.pubkey]).
|
||||
* mode always starts the agent and includes a WhereToRunSection;
|
||||
* definition-edit passes the caller's PersonaDialogState-derived props
|
||||
* through unchanged (edit/duplicate/import). instance-edit renders
|
||||
* AgentInstanceEditDialog (persistent mount + `open` toggle — its reset
|
||||
* lifecycle is keyed on [open, agent.pubkey]).
|
||||
*/
|
||||
export function AgentDialog(props: AgentDialogProps) {
|
||||
if (props.mode === "instance-edit") {
|
||||
@@ -112,47 +109,44 @@ function AgentCreateDialogRouter({
|
||||
runtimesLoading,
|
||||
onSubmitDefinition,
|
||||
}: AgentDialogCreateProps) {
|
||||
const [startAfterCreate, setStartAfterCreate] = React.useState(true);
|
||||
const [runDraft, setRunDraft] = React.useState(emptyWhereToRunDraft);
|
||||
// Stable identity across toggle flips — AgentDefinitionDialog re-initializes its
|
||||
// fields whenever `initialValues` changes.
|
||||
const { availability: meshAvailability } = useMeshAvailability();
|
||||
const meshUnavailable =
|
||||
meshAvailability != null && !meshAvailability.available;
|
||||
// The cleanup effect below resets the persisted draft after this render. The
|
||||
// submit path must not wait for that effect: it uses the local fallback
|
||||
// immediately when a selected mesh target disappears.
|
||||
const effectiveRunDraft =
|
||||
meshUnavailable && runDraft.runOn === "mesh"
|
||||
? emptyWhereToRunDraft
|
||||
: runDraft;
|
||||
// Persist the fallback after the synchronous guard has made the render safe.
|
||||
// This cleanup keeps a future availability recovery from restoring an invalid
|
||||
// mesh selection.
|
||||
React.useEffect(() => {
|
||||
if (meshUnavailable && runDraft.runOn === "mesh") {
|
||||
setRunDraft(emptyWhereToRunDraft);
|
||||
}
|
||||
}, [meshUnavailable, runDraft.runOn]);
|
||||
const initialValues = React.useMemo(
|
||||
() => createPersonaDialogState().initialValues,
|
||||
[],
|
||||
);
|
||||
|
||||
const copy = definitionCreateDialogState(startAfterCreate);
|
||||
const copy = createPersonaDialogState();
|
||||
|
||||
return (
|
||||
<AgentDefinitionDialog
|
||||
createFooterSlot={
|
||||
<label
|
||||
className="flex cursor-pointer items-center gap-2 text-sm text-muted-foreground"
|
||||
htmlFor="agent-dialog-start-toggle"
|
||||
>
|
||||
<Switch
|
||||
checked={startAfterCreate}
|
||||
data-testid="agent-dialog-start-toggle"
|
||||
disabled={isDefinitionPending}
|
||||
id="agent-dialog-start-toggle"
|
||||
onCheckedChange={setStartAfterCreate}
|
||||
/>
|
||||
Start agent after creation
|
||||
</label>
|
||||
}
|
||||
createRunSection={
|
||||
// "Where to run" is instance state: with the start toggle off no
|
||||
// instance exists, so the section disappears instead of dangling.
|
||||
startAfterCreate ? (
|
||||
<WhereToRunSection
|
||||
draft={runDraft}
|
||||
isPending={isDefinitionPending}
|
||||
onDraftChange={setRunDraft}
|
||||
/>
|
||||
) : null
|
||||
<WhereToRunSection
|
||||
draft={runDraft}
|
||||
isPending={isDefinitionPending}
|
||||
meshAvailability={meshAvailability}
|
||||
onDraftChange={setRunDraft}
|
||||
/>
|
||||
}
|
||||
createSubmitBlocked={!canSubmitWhereToRun(runDraft, startAfterCreate)}
|
||||
createRunOnMesh={startAfterCreate && runDraft.runOn === "mesh"}
|
||||
createSubmitBlocked={!canSubmitWhereToRun(effectiveRunDraft)}
|
||||
createRunOnMesh={effectiveRunDraft.runOn === "mesh"}
|
||||
description={copy.description}
|
||||
error={definitionError}
|
||||
initialValues={initialValues}
|
||||
@@ -161,8 +155,8 @@ function AgentCreateDialogRouter({
|
||||
onSubmit={async (input) => {
|
||||
const submitted = await onSubmitDefinition(
|
||||
input,
|
||||
intentForStartToggle(startAfterCreate),
|
||||
resolveBackendIntent(runDraft, startAfterCreate),
|
||||
"definition_start",
|
||||
resolveBackendIntent(effectiveRunDraft),
|
||||
);
|
||||
if (submitted) {
|
||||
onOpenChange(false);
|
||||
|
||||
@@ -63,7 +63,7 @@ export function AgentsView() {
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: mount-only shortcut subscription; openUnifiedCreate only calls stable setState-backed callbacks
|
||||
React.useEffect(() => {
|
||||
// The app-wide "create agent" shortcut routes to the unified definition
|
||||
// flow (B5): one create path, with the start-after-create toggle on.
|
||||
// flow (B5): one create path, always starting the agent after creation.
|
||||
if (consumePendingOpenCreateAgent()) {
|
||||
openUnifiedCreate();
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ import * as React from "react";
|
||||
import { useBackendProvidersQuery } from "@/features/agents/hooks";
|
||||
import { RelayMeshAgentSection } from "@/features/mesh-compute/ui/RelayMeshAgentSection";
|
||||
import { probeBackendProvider } from "@/shared/api/tauri";
|
||||
import type { MeshAvailability } from "@/shared/api/tauriMesh";
|
||||
|
||||
import { ProviderConfigFields } from "./ProviderConfigFields";
|
||||
import { emptyWhereToRunDraft, type WhereToRunDraft } from "./whereToRunIntent";
|
||||
@@ -16,11 +17,9 @@ import { emptyWhereToRunDraft, type WhereToRunDraft } from "./whereToRunIntent";
|
||||
* (`resolveBackendIntent`) and gates the submit button
|
||||
* (`canSubmitWhereToRun`).
|
||||
*
|
||||
* Only rendered while the start-after-create toggle is ON — "where to run"
|
||||
* is instance state, and with the toggle off no instance exists. The parent
|
||||
* discards the draft at submit when the toggle is off (the stale-intent
|
||||
* guard), so a selection made before toggling off can never silently ride
|
||||
* a definition-only create.
|
||||
* Always rendered in the create flow — agents are always started after
|
||||
* creation. The parent owns the availability-flip guard so visibility and the
|
||||
* submit path share one snapshot; this section only renders that snapshot.
|
||||
*
|
||||
* Honest-copy note: unlike the legacy create dialog, the mesh preset never
|
||||
* overwrites the definition's fields — only the minted instance carries the
|
||||
@@ -30,10 +29,12 @@ import { emptyWhereToRunDraft, type WhereToRunDraft } from "./whereToRunIntent";
|
||||
export function WhereToRunSection({
|
||||
draft,
|
||||
isPending,
|
||||
meshAvailability,
|
||||
onDraftChange,
|
||||
}: {
|
||||
draft: WhereToRunDraft;
|
||||
isPending: boolean;
|
||||
meshAvailability: MeshAvailability | null;
|
||||
onDraftChange: (next: WhereToRunDraft) => void;
|
||||
}) {
|
||||
const backendProvidersQuery = useBackendProvidersQuery();
|
||||
@@ -174,6 +175,7 @@ export function WhereToRunSection({
|
||||
{!isProviderMode ? (
|
||||
<>
|
||||
<RelayMeshAgentSection
|
||||
availability={meshAvailability}
|
||||
current={{
|
||||
// The definition's own fields are never overwritten by the mesh
|
||||
// preset — only the minted instance carries it — so the override
|
||||
|
||||
@@ -1,12 +1,7 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
definitionCreateDialogState,
|
||||
intentForStartToggle,
|
||||
resolveCreateIntent,
|
||||
} from "./agentCreateIntent.ts";
|
||||
import { createPersonaDialogState } from "./personaDialogState.ts";
|
||||
import { resolveCreateIntent } from "./agentCreateIntent.ts";
|
||||
|
||||
test("resolveCreateIntent defaults to quick-start for un-migrated callers", () => {
|
||||
// PersonaDialog's duplicate path calls handleSubmit without an intent until
|
||||
@@ -19,25 +14,3 @@ test("resolveCreateIntent passes explicit intents through", () => {
|
||||
assert.equal(resolveCreateIntent("definition"), "definition");
|
||||
assert.equal(resolveCreateIntent("definition_start"), "definition_start");
|
||||
});
|
||||
|
||||
test("intentForStartToggle maps the toggle to definition-family intents", () => {
|
||||
assert.equal(intentForStartToggle(true), "definition_start");
|
||||
assert.equal(intentForStartToggle(false), "definition");
|
||||
});
|
||||
|
||||
test("toggle-on dialog copy is exactly the legacy create copy", () => {
|
||||
assert.deepEqual(
|
||||
definitionCreateDialogState(true),
|
||||
createPersonaDialogState(),
|
||||
);
|
||||
});
|
||||
|
||||
test("toggle-off dialog copy differs only in description", () => {
|
||||
const legacy = createPersonaDialogState();
|
||||
const definitionOnly = definitionCreateDialogState(false);
|
||||
assert.equal(definitionOnly.title, legacy.title);
|
||||
assert.equal(definitionOnly.submitLabel, legacy.submitLabel);
|
||||
assert.deepEqual(definitionOnly.initialValues, legacy.initialValues);
|
||||
assert.notEqual(definitionOnly.description, legacy.description);
|
||||
assert.match(definitionOnly.description, /without starting/);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,3 @@
|
||||
import {
|
||||
createPersonaDialogState,
|
||||
type PersonaDialogState,
|
||||
} from "./personaDialogState";
|
||||
|
||||
/**
|
||||
* What the user is creating from the unified create dialog.
|
||||
*
|
||||
@@ -23,30 +18,3 @@ export function resolveCreateIntent(
|
||||
): AgentCreateIntent {
|
||||
return intent ?? "definition_start";
|
||||
}
|
||||
|
||||
/** Maps the "Start agent after create" toggle to a definition-family intent. */
|
||||
export function intentForStartToggle(
|
||||
startAfterCreate: boolean,
|
||||
): AgentCreateIntent {
|
||||
return startAfterCreate ? "definition_start" : "definition";
|
||||
}
|
||||
|
||||
/**
|
||||
* Dialog copy for the definition-family create dialog. The toggle-on copy is
|
||||
* derived from `createPersonaDialogState` so it cannot drift from the legacy
|
||||
* create flow it replaces.
|
||||
*/
|
||||
export function definitionCreateDialogState(
|
||||
startAfterCreate: boolean,
|
||||
): PersonaDialogState {
|
||||
const legacy = createPersonaDialogState();
|
||||
if (startAfterCreate) {
|
||||
return legacy;
|
||||
}
|
||||
|
||||
return {
|
||||
...legacy,
|
||||
description:
|
||||
"Create an agent without starting it. You can start it from its card at any time.",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -241,36 +241,6 @@ test("localMode_gate_bypassed_for_meshMode", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("localMode_gate_active_when_mesh_selected_but_startAfterCreate_off", () => {
|
||||
// Regression: when a user selects relay-mesh then turns off "Start agent
|
||||
// after creation", the run draft is hidden and the backend intent is
|
||||
// discarded — the saved definition has no mesh backing. The submit gate
|
||||
// must require provider/model in this case.
|
||||
//
|
||||
// AgentDialog.tsx computes: createRunOnMesh = startAfterCreate && runDraft.runOn === "mesh"
|
||||
// With startAfterCreate=false that evaluates to false regardless of runDraft,
|
||||
// so computeLocalModeGate receives useMesh:false and must enforce the gate.
|
||||
const result = computeLocalModeGate({
|
||||
envVars: {},
|
||||
isProviderMode: false,
|
||||
model: "",
|
||||
provider: "",
|
||||
runtimeId: "buzz-agent",
|
||||
useMesh: false, // = startAfterCreate(false) && runDraft.runOn("mesh")
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
result.satisfied,
|
||||
false,
|
||||
"gate must NOT be bypassed when startAfterCreate is off, even if runDraft was set to mesh",
|
||||
);
|
||||
assert.deepEqual(
|
||||
result.missingNormalizedFields,
|
||||
["provider", "model"],
|
||||
"both provider and model must be flagged as missing for the stale-mesh path",
|
||||
);
|
||||
});
|
||||
|
||||
// ── IMPORTANT 2: requiredEnvKeys surfaces correctly ───────────────────────
|
||||
|
||||
test("localMode_requiredEnvKeys_surfaces_anthropicKey", () => {
|
||||
|
||||
@@ -44,69 +44,45 @@ function meshDraft(overrides = {}) {
|
||||
};
|
||||
}
|
||||
|
||||
// ── Stale-intent edge (Pinky pin 2) ─────────────────────────────────────────
|
||||
|
||||
test("start toggle off discards a provider selection at submit", () => {
|
||||
assert.equal(
|
||||
resolveBackendIntent(providerDraft(), false),
|
||||
null,
|
||||
"definition-only create must never carry a backend intent",
|
||||
);
|
||||
});
|
||||
|
||||
test("start toggle off discards a mesh selection at submit", () => {
|
||||
assert.equal(resolveBackendIntent(meshDraft(), false), null);
|
||||
});
|
||||
|
||||
test("start toggle off always allows submit regardless of draft state", () => {
|
||||
// Incomplete provider config with the toggle off: no instance is minted,
|
||||
// so the draft must not block the definition-only create.
|
||||
const incomplete = providerDraft({ providerConfig: {} });
|
||||
assert.equal(canSubmitWhereToRun(incomplete, false), true);
|
||||
});
|
||||
|
||||
// ── Submit gating carries over (Pinky pin 3) ────────────────────────────────
|
||||
// ── Submit gating ───────────────────────────────────────────────────────────
|
||||
|
||||
test("provider selection blocks submit until the probe completes", () => {
|
||||
const unprobed = providerDraft({ probedProvider: null });
|
||||
assert.equal(canSubmitWhereToRun(unprobed, true), false);
|
||||
assert.equal(canSubmitWhereToRun(unprobed), false);
|
||||
});
|
||||
|
||||
test("provider selection blocks submit while required config is missing", () => {
|
||||
const missing = providerDraft({ providerConfig: { size: "3" } });
|
||||
assert.equal(canSubmitWhereToRun(missing, true), false);
|
||||
assert.equal(canSubmitWhereToRun(missing), false);
|
||||
assert.equal(providerConfigComplete(missing), false);
|
||||
});
|
||||
|
||||
test("complete provider config allows submit", () => {
|
||||
assert.equal(canSubmitWhereToRun(providerDraft(), true), true);
|
||||
assert.equal(canSubmitWhereToRun(providerDraft()), true);
|
||||
});
|
||||
|
||||
test("mesh selection blocks submit without a concrete serve target", () => {
|
||||
assert.equal(
|
||||
canSubmitWhereToRun(meshDraft({ meshTarget: null }), true),
|
||||
canSubmitWhereToRun(meshDraft({ meshTarget: null })),
|
||||
false,
|
||||
"a model name alone is not a startable mesh selection",
|
||||
);
|
||||
assert.equal(
|
||||
canSubmitWhereToRun(meshDraft({ meshModelId: "" }), true),
|
||||
false,
|
||||
);
|
||||
assert.equal(canSubmitWhereToRun(meshDraft(), true), true);
|
||||
assert.equal(canSubmitWhereToRun(meshDraft({ meshModelId: "" })), false);
|
||||
assert.equal(canSubmitWhereToRun(meshDraft()), true);
|
||||
});
|
||||
|
||||
test("local never gates submit", () => {
|
||||
assert.equal(canSubmitWhereToRun(emptyWhereToRunDraft, true), true);
|
||||
assert.equal(canSubmitWhereToRun(emptyWhereToRunDraft), true);
|
||||
});
|
||||
|
||||
// ── Intent resolution ────────────────────────────────────────────────────────
|
||||
|
||||
test("local draft resolves to null intent", () => {
|
||||
assert.equal(resolveBackendIntent(emptyWhereToRunDraft, true), null);
|
||||
assert.equal(resolveBackendIntent(emptyWhereToRunDraft), null);
|
||||
});
|
||||
|
||||
test("provider draft resolves with coerced config values", () => {
|
||||
const intent = resolveBackendIntent(providerDraft(), true);
|
||||
const intent = resolveBackendIntent(providerDraft());
|
||||
assert.deepEqual(intent, {
|
||||
type: "provider",
|
||||
id: "blox",
|
||||
@@ -115,7 +91,7 @@ test("provider draft resolves with coerced config values", () => {
|
||||
});
|
||||
|
||||
test("mesh draft resolves with target and patch", () => {
|
||||
const intent = resolveBackendIntent(meshDraft(), true);
|
||||
const intent = resolveBackendIntent(meshDraft());
|
||||
assert.equal(intent.type, "mesh");
|
||||
assert.equal(intent.modelId, "mesh/model:Q4");
|
||||
assert.equal(intent.target.endpointAddr, "10.0.0.1:9337");
|
||||
@@ -123,12 +99,6 @@ test("mesh draft resolves with target and patch", () => {
|
||||
});
|
||||
|
||||
test("mesh draft without patch or target resolves to null, not a broken intent", () => {
|
||||
assert.equal(
|
||||
resolveBackendIntent(meshDraft({ meshPatch: null }), true),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
resolveBackendIntent(meshDraft({ meshTarget: null }), true),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveBackendIntent(meshDraft({ meshPatch: null })), null);
|
||||
assert.equal(resolveBackendIntent(meshDraft({ meshTarget: null })), null);
|
||||
});
|
||||
|
||||
@@ -45,16 +45,8 @@ export function providerConfigComplete(draft: WhereToRunDraft): boolean {
|
||||
* legacy dialog's gates: provider mode blocks until the probe succeeds and
|
||||
* required config is filled; mesh mode blocks until a concrete serve target
|
||||
* (not just a model name) is selected. Local always passes.
|
||||
*
|
||||
* When `startAfterCreate` is false there is no instance, so the draft is
|
||||
* irrelevant and submit is always allowed (the intent is discarded — see
|
||||
* resolveBackendIntent).
|
||||
*/
|
||||
export function canSubmitWhereToRun(
|
||||
draft: WhereToRunDraft,
|
||||
startAfterCreate: boolean,
|
||||
): boolean {
|
||||
if (!startAfterCreate) return true;
|
||||
export function canSubmitWhereToRun(draft: WhereToRunDraft): boolean {
|
||||
if (draft.runOn === "mesh") {
|
||||
return draft.meshModelId.trim().length > 0 && draft.meshTarget != null;
|
||||
}
|
||||
@@ -63,17 +55,12 @@ export function canSubmitWhereToRun(
|
||||
|
||||
/**
|
||||
* Resolve the draft into the BackendIntent the instance mint should carry.
|
||||
*
|
||||
* Returns null for local AND whenever `startAfterCreate` is false: with the
|
||||
* start toggle off no instance exists, so a leftover provider/mesh selection
|
||||
* must be discarded at submit — never silently attached to a definition-only
|
||||
* create (the stale-intent edge).
|
||||
* Returns null for local — no backend override needed.
|
||||
*/
|
||||
export function resolveBackendIntent(
|
||||
draft: WhereToRunDraft,
|
||||
startAfterCreate: boolean,
|
||||
): BackendIntent | null {
|
||||
if (!startAfterCreate || draft.runOn === "local") {
|
||||
if (draft.runOn === "local") {
|
||||
return null;
|
||||
}
|
||||
if (draft.runOn === "mesh") {
|
||||
|
||||
@@ -1,14 +1,17 @@
|
||||
import * as React from "react";
|
||||
import { AlertCircle, Network } from "lucide-react";
|
||||
|
||||
import { meshAgentPreset, type MeshServeTarget } from "@/shared/api/tauriMesh";
|
||||
import {
|
||||
meshAgentPreset,
|
||||
type MeshAvailability,
|
||||
type MeshServeTarget,
|
||||
} from "@/shared/api/tauriMesh";
|
||||
import { Switch } from "@/shared/ui/switch";
|
||||
|
||||
import {
|
||||
detectMeshPresetOverrides,
|
||||
meshAgentPresetPatch,
|
||||
} from "../applyMeshAgentPreset";
|
||||
import { useMeshAvailability } from "../hooks/useMeshAvailability";
|
||||
|
||||
/**
|
||||
* The "Run on relay mesh" entry in the agent create flow (WhereToRunSection).
|
||||
@@ -21,6 +24,7 @@ import { useMeshAvailability } from "../hooks/useMeshAvailability";
|
||||
* component is purely a controller for the mesh-specific subset.
|
||||
*/
|
||||
export function RelayMeshAgentSection({
|
||||
availability,
|
||||
current,
|
||||
useMesh,
|
||||
targetEndpointAddr,
|
||||
@@ -28,6 +32,11 @@ export function RelayMeshAgentSection({
|
||||
onModelIdChange,
|
||||
onTargetChange,
|
||||
}: {
|
||||
/**
|
||||
* Parent-owned mesh availability. Sharing this snapshot keeps visibility and
|
||||
* draft reset synchronized when availability changes mid-dialog.
|
||||
*/
|
||||
availability: MeshAvailability | null;
|
||||
/**
|
||||
* Current draft state of the *fields the preset would overwrite*. Used to
|
||||
* compute the override warning ("Using Relay mesh — overrides this
|
||||
@@ -56,27 +65,19 @@ export function RelayMeshAgentSection({
|
||||
) => void;
|
||||
onTargetChange: (target: MeshServeTarget | null) => void;
|
||||
}) {
|
||||
const { availability, error } = useMeshAvailability();
|
||||
const [presetError, setPresetError] = React.useState<string | null>(null);
|
||||
|
||||
const disabled = availability == null || !availability.available;
|
||||
const disabledReason =
|
||||
availability == null
|
||||
? (error ?? "Checking relay mesh availability…")
|
||||
: (availability.reason ?? "The relay mesh isn't available right now.");
|
||||
|
||||
// Compute overrides from the currently-selected model's preset, *not* from
|
||||
// an arbitrary one — the warning must reflect what'll actually happen.
|
||||
const [overrides, setOverrides] = React.useState<string[]>([]);
|
||||
|
||||
// Null means the first availability fetch hasn't succeeded — either still
|
||||
// loading, or the backend was built without mesh-llm and never will resolve.
|
||||
// Hide the card entirely rather than showing a permanently disabled toggle.
|
||||
if (availability === null) {
|
||||
// Hide the card when availability hasn't resolved (built without mesh-llm,
|
||||
// still loading) or when the mesh is genuinely unavailable (no serve nodes).
|
||||
if (availability === null || !availability.available) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const targets = availability?.serveTargets ?? [];
|
||||
const targets = availability.serveTargets;
|
||||
const selectedValue = targetEndpointAddr;
|
||||
|
||||
async function pickTarget(endpointAddr: string) {
|
||||
@@ -128,15 +129,12 @@ export function RelayMeshAgentSection({
|
||||
Run on relay mesh
|
||||
</label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{disabled
|
||||
? disabledReason
|
||||
: "Use a member's shared compute — no API key needed."}
|
||||
Use a member's shared compute — no API key needed.
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
checked={useMesh}
|
||||
data-testid="agent-relay-mesh-toggle"
|
||||
disabled={disabled}
|
||||
id="agent-relay-mesh-toggle"
|
||||
onCheckedChange={onUseMeshChange}
|
||||
/>
|
||||
|
||||
@@ -764,6 +764,12 @@ declare global {
|
||||
command: string;
|
||||
payload: unknown;
|
||||
}>;
|
||||
/** Results emitted only after a mocked mesh-availability request resolves. */
|
||||
__BUZZ_E2E_MESH_AVAILABILITY_RESULTS__?: Array<{
|
||||
admitted: boolean;
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
}>;
|
||||
__BUZZ_E2E_WEBVIEW_ZOOM__?: number;
|
||||
__BUZZ_E2E_HAS_MOCK_LIVE_SUBSCRIPTION__?: (input: {
|
||||
channelName: string;
|
||||
@@ -8208,6 +8214,7 @@ export function maybeInstallE2eTauriMocks() {
|
||||
window.__BUZZ_E2E_COMMANDS__ = [];
|
||||
window.__BUZZ_E2E_COMMAND_PAYLOADS__ = [];
|
||||
window.__BUZZ_E2E_COMMAND_LOG__ = [];
|
||||
window.__BUZZ_E2E_MESH_AVAILABILITY_RESULTS__ = [];
|
||||
window.__BUZZ_E2E_SIGNED_EVENTS__ = [];
|
||||
window.__BUZZ_E2E_WEBVIEW_ZOOM__ = 1;
|
||||
window.__BUZZ_E2E_EMIT_MOCK_MESSAGE__ = ({
|
||||
@@ -8454,12 +8461,16 @@ export function maybeInstallE2eTauriMocks() {
|
||||
window.__BUZZ_E2E_COMMAND_LOG__?.push({ command, payload });
|
||||
|
||||
switch (command) {
|
||||
case "mesh_availability":
|
||||
return {
|
||||
case "mesh_availability": {
|
||||
const result = {
|
||||
capable: true,
|
||||
admitted: mockMeshState.admitted,
|
||||
available: mockMeshState.admitted,
|
||||
reason: mockMeshState.admitted ? null : mockMeshState.denyReason,
|
||||
available: mockMeshState.admitted && mockMeshState.models.length > 0,
|
||||
reason: !mockMeshState.admitted
|
||||
? mockMeshState.denyReason
|
||||
: mockMeshState.models.length === 0
|
||||
? "no relay mesh serve targets are available"
|
||||
: null,
|
||||
models: mockMeshState.models,
|
||||
serveTargets: mockMeshState.models.map((model) => ({
|
||||
modelId: model.id,
|
||||
@@ -8476,6 +8487,17 @@ export function maybeInstallE2eTauriMocks() {
|
||||
deviceName: "Mock desktop",
|
||||
})),
|
||||
};
|
||||
return Promise.resolve(result).then((resolved) => {
|
||||
// Unlike the command log above, record this only after the mocked
|
||||
// result has resolved so E2E can distinguish it from loading.
|
||||
window.__BUZZ_E2E_MESH_AVAILABILITY_RESULTS__?.push({
|
||||
admitted: resolved.admitted,
|
||||
available: resolved.available,
|
||||
reason: resolved.reason,
|
||||
});
|
||||
return resolved;
|
||||
});
|
||||
}
|
||||
case "mesh_installed_models":
|
||||
return mockMeshState.models;
|
||||
case "mesh_node_status":
|
||||
|
||||
@@ -13,6 +13,11 @@ type E2eWindow = Window & {
|
||||
__BUZZ_E2E_INVOKE_MOCK_COMMAND__?: unknown;
|
||||
__TAURI_INTERNALS__?: { invoke?: unknown };
|
||||
__BUZZ_E2E_COMMANDS__?: string[];
|
||||
__BUZZ_E2E_MESH_AVAILABILITY_RESULTS__?: Array<{
|
||||
admitted: boolean;
|
||||
available: boolean;
|
||||
reason: string | null;
|
||||
}>;
|
||||
__BUZZ_E2E_SIGNED_EVENTS__?: Array<{
|
||||
content: string;
|
||||
kind: number;
|
||||
@@ -57,7 +62,11 @@ async function signedEvents(page: import("@playwright/test").Page) {
|
||||
|
||||
async function setMesh(
|
||||
page: import("@playwright/test").Page,
|
||||
mesh: { admitted?: boolean; denyReason?: string },
|
||||
mesh: {
|
||||
admitted?: boolean;
|
||||
models?: Array<{ id: string; name: string | null }>;
|
||||
denyReason?: string;
|
||||
},
|
||||
) {
|
||||
await page.evaluate((m) => {
|
||||
(window as E2eWindow).__BUZZ_E2E_SET_MESH__?.(m);
|
||||
@@ -285,10 +294,23 @@ test("a non-member cannot enable relay-mesh — membership is the gate", async (
|
||||
await openNewAgentMenu(page);
|
||||
await page.getByRole("menuitem", { name: /^New agent$/ }).click();
|
||||
|
||||
// The relay-mesh toggle stays disabled — a non-member cannot even opt into
|
||||
// running on the mesh, let alone spawn an agent against it.
|
||||
// Wait for the denied result, not just command invocation, so the hidden
|
||||
// assertion cannot pass while availability is still loading.
|
||||
await expect
|
||||
.poll(async () =>
|
||||
page.evaluate(
|
||||
() =>
|
||||
(window as E2eWindow).__BUZZ_E2E_MESH_AVAILABILITY_RESULTS__?.some(
|
||||
(result) => !result.admitted && !result.available,
|
||||
) ?? false,
|
||||
),
|
||||
)
|
||||
.toBe(true);
|
||||
|
||||
// The relay-mesh section is hidden entirely for non-members — availability
|
||||
// controls visibility, not just the enabled state of the toggle.
|
||||
const toggle = page.getByTestId("agent-relay-mesh-toggle");
|
||||
await expect(toggle).toBeDisabled();
|
||||
await expect(toggle).toHaveCount(0);
|
||||
|
||||
// The flow never reaches an ensure-or-spawn, because membership gates the
|
||||
// entry point itself. Sanity-check we never created an agent on the mesh.
|
||||
@@ -296,6 +318,69 @@ test("a non-member cannot enable relay-mesh — membership is the gate", async (
|
||||
expect(seq).not.toContain("create_managed_agent");
|
||||
});
|
||||
|
||||
test("mesh availability flip mid-dialog resets the draft — no stale mesh intent on submit", async ({
|
||||
page,
|
||||
}) => {
|
||||
await gotoApp(page);
|
||||
await page.getByTestId("open-agents-view").click();
|
||||
await openNewAgentMenu(page);
|
||||
await page.getByRole("menuitem", { name: /^New agent$/ }).click();
|
||||
await page.locator("#persona-display-name").fill("Mesh flip agent");
|
||||
|
||||
// Select a mesh target while availability is good.
|
||||
const toggle = page.getByTestId("agent-relay-mesh-toggle");
|
||||
await expect(toggle).toBeEnabled({ timeout: 10_000 });
|
||||
await toggle.click();
|
||||
await page
|
||||
.getByTestId("agent-relay-mesh-model")
|
||||
.selectOption({ label: "SmolLM2 135M — Mock desktop" });
|
||||
|
||||
// Wait for the asynchronous preset lookup to complete, proving the selected
|
||||
// mesh target would otherwise supply a submit-ready mesh BackendIntent.
|
||||
const submit = page.getByTestId("persona-dialog-submit");
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
// Flip availability to unavailable mid-dialog (simulates discovery losing
|
||||
// every serve target after the user already selected one). The parent must
|
||||
// synchronously turn the selected mesh draft into a local draft for submit;
|
||||
// the passive cleanup is only allowed to persist that already-safe state.
|
||||
await setMesh(page, { models: [] });
|
||||
|
||||
// The mesh section hides and the local gate applies again; a name alone
|
||||
// cannot create an unrunnable local agent.
|
||||
await expect(toggle).toHaveCount(0, { timeout: 10_000 });
|
||||
await expect(submit).toBeDisabled();
|
||||
|
||||
// Explicitly configure the local fallback. Databricks v2 has no typed API
|
||||
// key; its required host is an endpoint, not a secret credential.
|
||||
await page.locator("#persona-llm-provider").click();
|
||||
await page
|
||||
.getByRole("menuitemradio", { name: "Databricks v2", exact: true })
|
||||
.click();
|
||||
await page.locator("#persona-model").click();
|
||||
await page
|
||||
.getByRole("button", { name: "Custom model...", exact: true })
|
||||
.click();
|
||||
await page.getByLabel("Custom model ID").fill("mock-local-model");
|
||||
await page
|
||||
.getByLabel("Value for DATABRICKS_HOST")
|
||||
.fill("https://example.cloud.databricks.com");
|
||||
await expect(submit).toBeEnabled();
|
||||
|
||||
// Submit the explicitly-configured local agent. It must not retain mesh
|
||||
// intent after availability disappeared.
|
||||
const before = (await commands(page)).length;
|
||||
await submit.click();
|
||||
await expect
|
||||
.poll(async () => (await commands(page)).slice(before))
|
||||
.toContain("create_managed_agent");
|
||||
|
||||
// The local create must not prepare a relay-mesh client.
|
||||
expect((await commands(page)).slice(before)).not.toContain(
|
||||
"mesh_prepare_relay_mesh_client",
|
||||
);
|
||||
});
|
||||
|
||||
test("saved relay-mesh agents restart via the backend serve-target preflight", async ({
|
||||
page,
|
||||
}) => {
|
||||
|
||||
@@ -165,8 +165,8 @@ test("create agent supports parallelism and system prompt overrides", async ({
|
||||
await expect(page.locator("#persona-parallelism")).toBeVisible();
|
||||
await page.locator("#persona-parallelism").fill("3");
|
||||
|
||||
// The start-after-create toggle defaults ON, so submitting mints a running
|
||||
// instance whose behavioral quad resolves from the definition.
|
||||
// Submitting mints a running instance whose behavioral quad resolves from
|
||||
// the definition (agents always start after creation).
|
||||
await page.getByTestId("persona-dialog-submit").click();
|
||||
|
||||
await expect(
|
||||
|
||||
Reference in New Issue
Block a user