summaryrefslogtreecommitdiffhomepage
path: root/src/features/named-agents/logic
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
committerAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
commitfb7eb3105e1fbbdefdad9348f9132815b5bbe52d (patch)
treedeb95e97a917424ea67470b168b91349a41d1c08 /src/features/named-agents/logic
parent63588c3c69b32d180dc78a2cc187cc8aa22ad7e0 (diff)
downloaddispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.tar.gz
dispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.zip
feat(named-agents): sidebar view, synthetic provider, auto-save, snapshot semantics
Implement the frontend of the Named Agents feature (plan §5-§9): - New features/named-agents module: pure logic (types re-exported from @dispatch/wire + transport-contract, validation, provider/model options, snapshot helpers, network normalizers, auto-save status model) + an injected auto-save controller (name debounce, immediate provider/model save, monotonic stale-response guard) + the collapsible sidebar card view. - Store: /named-agents CRUD + boot seed; selectModel(model, snapshot?) persists the raw model WITH the namedAgent snapshot; draft promotion carries it; refreshModel restores ModelResponse.namedAgent. - Chat store setNamedAgent (sends namedAgent on chat.send); Tab.snapshot + setSnapshot (retained across rename/remove, persisted with tabs). - Model selector: synthetic Named Agents provider ALWAYS first; selecting one resolves to the raw model + records a snapshot; a retained snapshot of a renamed/removed agent still displays as unavailable (plan §7). - Composition root wires the view (viewKinds + loaded-modules manifest) + feeds the selector. Tests: logic + controller + selector + app-store CRUD (1246 pass), typecheck 0, biome clean (only pre-existing config infos pending a future migrate), build OK.
Diffstat (limited to 'src/features/named-agents/logic')
-rw-r--r--src/features/named-agents/logic/auto-save.test.ts157
-rw-r--r--src/features/named-agents/logic/auto-save.ts138
-rw-r--r--src/features/named-agents/logic/normalize.test.ts78
-rw-r--r--src/features/named-agents/logic/normalize.ts68
-rw-r--r--src/features/named-agents/logic/options.test.ts72
-rw-r--r--src/features/named-agents/logic/options.ts84
-rw-r--r--src/features/named-agents/logic/snapshot.test.ts35
-rw-r--r--src/features/named-agents/logic/snapshot.ts33
-rw-r--r--src/features/named-agents/logic/types.ts57
-rw-r--r--src/features/named-agents/logic/validate.test.ts58
-rw-r--r--src/features/named-agents/logic/validate.ts47
11 files changed, 827 insertions, 0 deletions
diff --git a/src/features/named-agents/logic/auto-save.test.ts b/src/features/named-agents/logic/auto-save.test.ts
new file mode 100644
index 0000000..24c9571
--- /dev/null
+++ b/src/features/named-agents/logic/auto-save.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from "vitest";
+import {
+ type AgentDraft,
+ draftModel,
+ draftValidity,
+ isDirty,
+ saveStatus,
+ saveStatusLabel,
+} from "./auto-save";
+
+const draft = (p: Partial<AgentDraft>): AgentDraft => ({
+ id: null,
+ name: "",
+ provider: "",
+ model: "",
+ ...p,
+});
+
+describe("draftModel", () => {
+ it("joins provider + model suffix", () => {
+ expect(draftModel(draft({ provider: "opencode-1", model: "claude" }))).toBe(
+ "opencode-1/claude",
+ );
+ });
+
+ it("falls back to provider-only when no model", () => {
+ expect(draftModel(draft({ provider: "opencode-1" }))).toBe("opencode-1");
+ });
+});
+
+describe("draftValidity", () => {
+ it("is usable when name, provider, and model are all valid", () => {
+ const v = draftValidity(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), []);
+ expect(v.usable).toBe(true);
+ expect(v.message).toBeNull();
+ });
+
+ it("is unusable with an empty name / duplicate / missing model", () => {
+ expect(draftValidity(draft({ name: "", provider: "p", model: "m" }), []).usable).toBe(false);
+ expect(
+ draftValidity(draft({ name: "coding", provider: "p", model: "m" }), ["Coding"]).usable,
+ ).toBe(false);
+ expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).usable).toBe(
+ false,
+ );
+ });
+
+ it("reports the first invalid field's message", () => {
+ expect(draftValidity(draft({ name: "", provider: "", model: "" }), []).message).toBe(
+ "Name is required.",
+ );
+ expect(
+ draftValidity(draft({ name: "coding", provider: "", model: "" }), ["Coding"]).message,
+ ).toBe("A named agent with this name already exists.");
+ expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).message).toBe(
+ "Select a provider.",
+ );
+ });
+});
+
+describe("isDirty", () => {
+ it("is false when the draft matches the saved definition", () => {
+ expect(
+ isDirty(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(false);
+ });
+
+ it("is true when name or model differ", () => {
+ expect(
+ isDirty(draft({ name: "Coding X", provider: "opencode-1", model: "claude" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ expect(
+ isDirty(draft({ name: "Coding", provider: "umans", model: "glm" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("saveStatus", () => {
+ const saved = { id: "a", name: "Coding", model: "opencode-1/claude" };
+
+ it("is saved when clean with an id", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ null,
+ ),
+ ).toBe("saved");
+ });
+
+ it("is dirty when input diverges from the saved definition", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding X", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ null,
+ ),
+ ).toBe("dirty");
+ });
+
+ it("is an idle draft when it has no id", () => {
+ expect(
+ saveStatus(
+ draft({ name: "Coding", provider: "opencode-1", model: "claude" }),
+ { id: null, name: "", model: "" },
+ false,
+ null,
+ ),
+ ).toBe("idle");
+ });
+
+ it("is saving while a save is in flight", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ true,
+ null,
+ ),
+ ).toBe("saving");
+ });
+
+ it("reports an error when the last save failed", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ "boom",
+ ),
+ ).toBe("error");
+ });
+});
+
+describe("saveStatusLabel", () => {
+ it("maps every status to a short label", () => {
+ expect(saveStatusLabel("idle")).toBe("Not saved");
+ expect(saveStatusLabel("dirty")).toBe("Not saved");
+ expect(saveStatusLabel("saving")).toBe("Saving…");
+ expect(saveStatusLabel("saved")).toBe("Saved");
+ expect(saveStatusLabel("error")).toBe("Save failed");
+ });
+});
diff --git a/src/features/named-agents/logic/auto-save.ts b/src/features/named-agents/logic/auto-save.ts
new file mode 100644
index 0000000..c89d12e
--- /dev/null
+++ b/src/features/named-agents/logic/auto-save.ts
@@ -0,0 +1,138 @@
+/**
+ * Pure auto-save model for a Named Agent card (plan §5.3/§5.4).
+ *
+ * A card has NO explicit Save button: provider/model changes save immediately,
+ * a name edit saves after a short debounce, and the card shows a status
+ * (`Not saved` / `Saving…` / `Saved` / `Save failed` + retry). This module is
+ * the PURE model of that: given the card's current DRAFT (the raw input
+ * controls), the last PERSISTED state, whether a save is in flight, and an
+ * error, it derives the card's display status and what a save would persist.
+ *
+ * The effectful coordination (debounce timers, dispatching saves, and the
+ * monotonic sequence that stops an older response clobbering a newer edit)
+ * lives in the component-wired controller (`ui/controller.svelte.ts`), which
+ * is itself thin over this purity.
+ */
+
+import { isUsableModel, isUsableName, normalizeName } from "./validate";
+
+/**
+ * The four auto-save lifecycle states a card reports. `"idle"` = a brand-new
+ * unsaved draft (never persisted yet) — shown as "Not saved". `"dirty"` = an
+ * existing card whose input differs from what was last saved — also "Not saved"
+ * until the (debounced/immediate) save lands. `"saving"` = a save is in flight.
+ * `"saved"` = current input matches the last persisted value. `"error"` = the
+ * last save failed (a retry is offered). A card that is not yet usable hovers
+ * in `"idle"` (never issued a save).
+ */
+export type SaveStatus = "idle" | "dirty" | "saving" | "saved" | "error";
+
+/** A card's current EDITOR state (the raw form controls). */
+export interface AgentDraft {
+ /** The persisted id once the card has been created; null for a new draft. */
+ readonly id: string | null;
+ /** The raw name input (may be unterminated/unedited). */
+ readonly name: string;
+ /** Selected provider id ("" when none). */
+ readonly provider: string;
+ /** Selected model suffix under `provider` ("" when none). */
+ readonly model: string;
+}
+
+/** The card's last PERSISTED definition (the server's canonical record). */
+export interface AgentSaved {
+ readonly id: string | null;
+ readonly name: string;
+ readonly model: string;
+}
+
+/** The full canonical `<provider>/<model>` name a draft would persist. */
+export function draftModel(draft: AgentDraft): string {
+ return draft.model === "" ? draft.provider : `${draft.provider}/${draft.model}`;
+}
+
+/** The full `<provider>/<model>` name a draft's model suffix maps to. */
+export interface DraftValidity {
+ readonly nameOk: boolean;
+ readonly modelOk: boolean;
+ /** True when the card is fully usable (name nonempty+unique, provider+model chosen). */
+ readonly usable: boolean;
+ /** A message for the first invalid field (for inline hints), or null when usable. */
+ readonly message: string | null;
+}
+
+/**
+ * Validate a draft against its sibling names (the OTHER agents' names, EXCLUDING
+ * this card's own id — uniqueness is among distinct agents).
+ */
+export function draftValidity(draft: AgentDraft, otherNames: readonly string[]): DraftValidity {
+ const nameMessage = nameErrorMessage(draft.name, otherNames);
+ const nameOk = nameMessage === null;
+ const modelOk = draft.provider !== "" && isUsableModel(draftModel(draft));
+ const usable = nameOk && modelOk;
+ const message = usable ? null : (nameMessage ?? modelMessage(draft));
+ return { nameOk, modelOk, usable, message };
+}
+
+function nameErrorMessage(name: string, otherNames: readonly string[]): string | null {
+ if (normalizeName(name).length === 0) return "Name is required.";
+ return isUsableName(name, otherNames) ? null : "A named agent with this name already exists.";
+}
+
+function modelMessage(draft: AgentDraft): string | null {
+ if (draft.provider === "") return "Select a provider.";
+ if (draft.model === "") return "Select a model.";
+ return "Select a valid model.";
+}
+
+/**
+ * Whether the current draft differs from what was last persisted — i.e. a save
+ * is owed. Draft is compared by its NAME (normalized) and full MODEL string.
+ */
+export function isDirty(draft: AgentDraft, saved: AgentSaved): boolean {
+ return (
+ normalizeName(draft.name) !== normalizeName(saved.name) || draftModel(draft) !== saved.model
+ );
+}
+
+/**
+ * The card's display status, purely from its orchestration inputs:
+ * - No id yet → a never-persisted draft → `"idle"` while unusable or unsaved,
+ * `"error"` if a create attempt failed.
+ * - In-flight save → `"saving"`.
+ * - Last save errored → `"error"`.
+ * - No id and clean (nothing to save) → `"idle"`.
+ * - Dirty (not yet (re)saved) → `"dirty"`.
+ * - Clean + has an id → `"saved"`.
+ */
+export function saveStatus(
+ draft: AgentDraft,
+ saved: AgentSaved,
+ saving: boolean,
+ error: string | null,
+): SaveStatus {
+ if (saving) return "saving";
+ if (error !== null) return "error";
+ const hasId = draft.id !== null || saved.id !== null;
+ if (!hasId) return "idle";
+ return isDirty(draft, saved) ? "dirty" : "saved";
+}
+
+/** Short human label for a save status (the card's status line). */
+export function saveStatusLabel(status: SaveStatus): string {
+ switch (status) {
+ case "saving":
+ return "Saving…";
+ case "saved":
+ return "Saved";
+ case "error":
+ return "Save failed";
+ default:
+ return "Not saved";
+ }
+}
+
+/** True iff a status warrants showing the card body as "saved clean" (no warning tint). */
+export function isClean(status: SaveStatus): boolean {
+ return status === "saved";
+}
diff --git a/src/features/named-agents/logic/normalize.test.ts b/src/features/named-agents/logic/normalize.test.ts
new file mode 100644
index 0000000..5009194
--- /dev/null
+++ b/src/features/named-agents/logic/normalize.test.ts
@@ -0,0 +1,78 @@
+import { describe, expect, it } from "vitest";
+import {
+ normalizeNamedAgent,
+ normalizeNamedAgentRecord,
+ normalizeNamedAgents,
+ toNamedAgentInput,
+} from "./normalize";
+import type { NamedAgent } from "./types";
+
+describe("normalizeNamedAgent", () => {
+ it("coerces a well-formed record", () => {
+ expect(normalizeNamedAgent({ id: "x", name: "Coding", model: "opencode-1/claude" })).toEqual({
+ id: "x",
+ name: "Coding",
+ model: "opencode-1/claude",
+ });
+ });
+
+ it("rejects a record missing any field", () => {
+ expect(normalizeNamedAgent({ id: "x", name: "Coding" })).toBeNull();
+ expect(normalizeNamedAgent({ name: "Coding", model: "m" })).toBeNull();
+ expect(normalizeNamedAgent(null)).toBeNull();
+ });
+});
+
+describe("normalizeNamedAgents", () => {
+ it("coerces a list body", () => {
+ const agents: readonly NamedAgent[] = normalizeNamedAgents({
+ namedAgents: [
+ { id: "a", name: "Coding", model: "opencode-1/claude" },
+ { id: "b", name: "Research", model: "umans/glm" },
+ ],
+ });
+ expect(agents).toHaveLength(2);
+ expect(agents[0]?.name).toBe("Coding");
+ });
+
+ it("drops malformed entries and survives a missing list key", () => {
+ expect(
+ normalizeNamedAgents({
+ namedAgents: [
+ { id: "a", name: "x" },
+ { id: "b", name: "y", model: "m" },
+ ],
+ }).map((a) => a.id),
+ ).toEqual(["b"]);
+ expect(normalizeNamedAgents({})).toEqual([]);
+ expect(normalizeNamedAgents(null)).toEqual([]);
+ });
+});
+
+describe("normalizeNamedAgentRecord", () => {
+ it("reads the canonical saved record", () => {
+ expect(
+ normalizeNamedAgentRecord({
+ namedAgent: { id: "a", name: "Coding", model: "opencode-1/claude" },
+ })?.id,
+ ).toBe("a");
+ });
+
+ it("returns null for a malformed record", () => {
+ expect(normalizeNamedAgentRecord({ namedAgent: {} })).toBeNull();
+ expect(normalizeNamedAgentRecord({})).toBeNull();
+ });
+});
+
+describe("toNamedAgentInput", () => {
+ it("trims a name and keeps the model", () => {
+ expect(toNamedAgentInput(" Coding ", "opencode-1/claude")).toEqual({
+ name: "Coding",
+ model: "opencode-1/claude",
+ });
+ });
+
+ it("defaults non-strings to empty", () => {
+ expect(toNamedAgentInput(null, 42)).toEqual({ name: "", model: "" });
+ });
+});
diff --git a/src/features/named-agents/logic/normalize.ts b/src/features/named-agents/logic/normalize.ts
new file mode 100644
index 0000000..c1acec2
--- /dev/null
+++ b/src/features/named-agents/logic/normalize.ts
@@ -0,0 +1,68 @@
+/**
+ * Pure network-seam normalizers for the Named Agents API — zero DOM, zero
+ * effects. The composition root coerces the UNTYPED JSON it receives from
+ * `GET/POST/PUT/DELETE /named-agents` through these so a malformed / partial
+ * response can never crash the renderer. Mirrors the concurrency / heartbeat
+ * network-seam guard pattern.
+ */
+
+import type { NamedAgent, NamedAgentInput } from "./types";
+
+function isRecord(value: unknown): value is Record<string, unknown> {
+ return value !== null && typeof value === "object";
+}
+
+function asString(value: unknown): string | null {
+ return typeof value === "string" && value.length > 0 ? value : null;
+}
+
+/**
+ * Coerce one untrusted record into a typed `NamedAgent`, or `null` when it is
+ * malformed (missing `id`, `name`, or `model` — or a non-string `model`).
+ */
+export function normalizeNamedAgent(value: unknown): NamedAgent | null {
+ if (!isRecord(value)) return null;
+ const id = asString(value.id);
+ const name = asString(value.name);
+ const model = asString(value.model);
+ if (id === null || name === null || model === null) return null;
+ return { id, name, model };
+}
+
+/**
+ * Coerce an untrusted `GET /named-agents` body into a typed Named Agent list.
+ * When the list key is absent/malformed, returns `[]` (defensive — the renderer
+ * shows the empty state rather than crashing).
+ */
+export function normalizeNamedAgents(data: unknown): readonly NamedAgent[] {
+ if (!isRecord(data) || !Array.isArray(data.namedAgents)) return [];
+ const out: NamedAgent[] = [];
+ for (const entry of data.namedAgents as readonly unknown[]) {
+ const agent = normalizeNamedAgent(entry);
+ if (agent !== null) out.push(agent);
+ }
+ return out;
+}
+
+/**
+ * Coerce an untrusted `POST`/`PUT /named-agents` body (the canonical saved
+ * record) into a typed `NamedAgent`, or `null` when malformed. The composition
+ * root surfaces an error when this returns null on an ok response.
+ */
+export function normalizeNamedAgentRecord(data: unknown): NamedAgent | null {
+ if (!isRecord(data)) return null;
+ return normalizeNamedAgent(data.namedAgent);
+}
+
+/**
+ * Coerce an untrusted `NamedAgentInput` at the create/update boundary so only
+ * clean strings reach the wire. Non-string fields drop to "" (name nonempty is
+ * validated separately; model nonempty too). The caller still runs name/model
+ * validation so a malformed body never reaches the backend.
+ */
+export function toNamedAgentInput(name: unknown, model: unknown): NamedAgentInput {
+ return {
+ name: typeof name === "string" ? name.trim() : "",
+ model: typeof model === "string" ? model : "",
+ };
+}
diff --git a/src/features/named-agents/logic/options.test.ts b/src/features/named-agents/logic/options.test.ts
new file mode 100644
index 0000000..8b774ab
--- /dev/null
+++ b/src/features/named-agents/logic/options.test.ts
@@ -0,0 +1,72 @@
+import { describe, expect, it } from "vitest";
+import {
+ fullModelFromSelection,
+ modelFromFull,
+ modelsForProvider,
+ providerFromModel,
+ providerLabel,
+ providerOptions,
+} from "./options";
+
+const MODELS = [
+ "opencode-2/gpt-5",
+ "opencode-1/claude-sonnet-4.5",
+ "umans/umans-glm-5.2",
+ "opencode-10/defiant",
+ "umans/umans-gpt-5",
+ "hyper/fast-1",
+] as const;
+
+describe("providerFromModel / modelFromFull", () => {
+ it("splits a <provider>/<model> on the first slash", () => {
+ expect(providerFromModel("opencode-1/claude-sonnet-4.5")).toBe("opencode-1");
+ expect(modelFromFull("opencode-1/claude-sonnet-4.5")).toBe("claude-sonnet-4.5");
+ });
+
+ it("treats a slashless name as all-provider", () => {
+ expect(providerFromModel("umans")).toBe("umans");
+ expect(modelFromFull("umans")).toBe("");
+ });
+});
+
+describe("providerOptions", () => {
+ it("sorts numbered OpenCode credentials numerically before other providers", () => {
+ expect(providerOptions([...MODELS])).toEqual([
+ "opencode-1",
+ "opencode-2",
+ "opencode-10",
+ "umans",
+ "hyper",
+ ]);
+ });
+
+ it("keeps first-seen order and dedupes", () => {
+ expect(providerOptions(["b/x", "a/y", "b/z"])).toEqual(["b", "a"]);
+ });
+});
+
+describe("modelsForProvider", () => {
+ it("returns only the model suffixes under the provider, in order", () => {
+ expect(modelsForProvider([...MODELS], "umans")).toEqual(["umans-glm-5.2", "umans-gpt-5"]);
+ expect(modelsForProvider([...MODELS], "hyper")).toEqual(["fast-1"]);
+ });
+
+ it("returns [] for an unknown provider", () => {
+ expect(modelsForProvider([...MODELS], "missing")).toEqual([]);
+ });
+});
+
+describe("providerLabel", () => {
+ it("labels numbered OpenCode providers, leaves others unchanged", () => {
+ expect(providerLabel("opencode-2")).toBe("OpenCode #2");
+ expect(providerLabel("umans")).toBe("umans");
+ });
+});
+
+describe("fullModelFromSelection", () => {
+ it("recombines a provider + model suffix", () => {
+ expect(fullModelFromSelection("opencode-1", "claude-sonnet-4.5")).toBe(
+ "opencode-1/claude-sonnet-4.5",
+ );
+ });
+});
diff --git a/src/features/named-agents/logic/options.ts b/src/features/named-agents/logic/options.ts
new file mode 100644
index 0000000..0946cbe
--- /dev/null
+++ b/src/features/named-agents/logic/options.ts
@@ -0,0 +1,84 @@
+/**
+ * Pure provider/model dropdown helpers for the Named Agents editor — zero DOM.
+ *
+ * A Named Agent's persisted `model` is the canonical `<provider>/<model>`
+ * identifier, but the edit UI splits it into a provider dropdown + a model
+ * dropdown (plan §3/§5.2). These helpers derive those dropdowns from the raw
+ * `GET /models` catalog — the SAME key grouping the regular model picker uses.
+ * Catalog latency is acceptable here (human configuration), and it must not
+ * affect the agent-facing list/summon hot paths (plan §9).
+ */
+
+/** The provider id prefix of a `<provider>/<model>` name (the part before the first `/`, or the whole string). */
+export function providerFromModel(full: string): string {
+ const i = full.indexOf("/");
+ return i === -1 ? full : full.slice(0, i);
+}
+
+/** The model suffix of a `<provider>/<model>` name (the part after the first `/`; empty when slashless). */
+export function modelFromFull(full: string): string {
+ const i = full.indexOf("/");
+ return i === -1 ? "" : full.slice(i + 1);
+}
+
+/** Recombine a provider + model suffix into a canonical `<provider>/<model>` name (provider-only if no model). */
+export function joinModel(provider: string, model: string): string {
+ return model === "" ? provider : `${provider}/${model}`;
+}
+
+/**
+ * Distinct provider ids across the catalog, in first-seen order, with numbered
+ * OpenCode credentials sorted NUMERICALLY before other providers (the same
+ * grouping the regular model picker uses — `opencode-2` before `opencode-10`,
+ * then everything else). A friendly display label is produced separately so the
+ * RAW id stays what gets persisted.
+ */
+export function providerOptions(models: readonly string[]): string[] {
+ const seen = new Set<string>();
+ const openCodeKeys: Array<{ key: string; n: number }> = [];
+ const otherKeys: string[] = [];
+ for (const full of models) {
+ const p = providerFromModel(full);
+ if (p === "" || seen.has(p)) continue;
+ seen.add(p);
+ const n = openCodeNumber(p);
+ if (n !== null) openCodeKeys.push({ key: p, n });
+ else otherKeys.push(p);
+ }
+ openCodeKeys.sort((a, b) => a.n - b.n);
+ return [...openCodeKeys.map((e) => e.key), ...otherKeys];
+}
+
+/** The numeric identity of a numbered OpenCode provider (`opencode-1` → 1), or null. */
+export function openCodeNumber(key: string): number | null {
+ const m = /^opencode-([1-9][0-9]*)$/.exec(key);
+ return m === null ? null : Number(m[1]);
+}
+
+/**
+ * Friendly display label for a provider id: `opencode-<n>` → "OpenCode #<n>";
+ * any other id unchanged. Presentation only — the RAW id is what gets persisted.
+ */
+export function providerLabel(provider: string): string {
+ const n = openCodeNumber(provider);
+ return n === null ? provider : `OpenCode #${n}`;
+}
+
+/** The model suffixes available under a given provider, in catalog order. */
+export function modelsForProvider(models: readonly string[], provider: string): string[] {
+ const out: string[] = [];
+ for (const full of models) {
+ if (providerFromModel(full) === provider) out.push(modelFromFull(full));
+ }
+ return out;
+}
+
+/**
+ * The model suffixes in the catalog that a Named Agent's model suffix may match.
+ * A saved Named Agent may reference a provider/model pair no longer present in a
+ * live catalog (e.g. a credential was removed); the editor then shows the saved
+ * RAW full name as an unavailable selection rather than silently rewriting it.
+ */
+export function fullModelFromSelection(provider: string, model: string): string {
+ return joinModel(provider, model);
+}
diff --git a/src/features/named-agents/logic/snapshot.test.ts b/src/features/named-agents/logic/snapshot.test.ts
new file mode 100644
index 0000000..3f1b5cf
--- /dev/null
+++ b/src/features/named-agents/logic/snapshot.test.ts
@@ -0,0 +1,35 @@
+import { describe, expect, it } from "vitest";
+import { findNamedAgent, snapshotFor } from "./snapshot";
+import type { NamedAgent } from "./types";
+
+const AGENTS: readonly NamedAgent[] = [
+ { id: "a1", name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" },
+ { id: "b2", name: "Research Agent", model: "umans/umans-glm-5.2" },
+];
+
+describe("snapshotFor", () => {
+ it("builds a snapshot carrying id, name, and the raw model", () => {
+ const coding = AGENTS[0];
+ if (coding === undefined) throw new Error("test fixture missing");
+ expect(coding).toBeDefined();
+ expect(snapshotFor(coding)).toEqual({
+ namedAgentId: "a1",
+ namedAgentName: "Coding Agent",
+ model: "opencode-1/claude-sonnet-4.5",
+ });
+ });
+});
+
+describe("findNamedAgent", () => {
+ it("finds by stable id", () => {
+ expect(findNamedAgent(AGENTS, "b2")?.name).toBe("Research Agent");
+ });
+
+ it("finds by name case-insensitively", () => {
+ expect(findNamedAgent(AGENTS, "coding agent")?.id).toBe("a1");
+ });
+
+ it("returns undefined for an unknown id/name", () => {
+ expect(findNamedAgent(AGENTS, "nope")).toBeUndefined();
+ });
+});
diff --git a/src/features/named-agents/logic/snapshot.ts b/src/features/named-agents/logic/snapshot.ts
new file mode 100644
index 0000000..482ceb3
--- /dev/null
+++ b/src/features/named-agents/logic/snapshot.ts
@@ -0,0 +1,33 @@
+/**
+ * Pure snapshot helpers for Named Agent selection (plan §7).
+ *
+ * Selecting a Named Agent for a conversation resolves it to its raw model AND
+ * records a `NamedAgentSelectionSnapshot` ({namedAgentId, namedAgentName, model})
+ * so the conversation retains how it was selected. Later edits to the shared
+ * Named Agent (rename / model change / removal) therefore do NOT retroactively
+ * change an existing conversation — the snapshot is what the UI keeps showing.
+ */
+
+import type { NamedAgent, NamedAgentSelectionSnapshot } from "./types";
+
+/** Build the snapshot a conversation would retain for a Named Agent selection. */
+export function snapshotFor(agent: NamedAgent): NamedAgentSelectionSnapshot {
+ return {
+ namedAgentId: agent.id,
+ namedAgentName: agent.name,
+ model: agent.model,
+ };
+}
+
+/**
+ * Find a Named Agent by stable id or (case-insensitively) by name. Used to
+ * resolve a name/id picked in the model selector back to its current definition.
+ * Returns undefined when unknown (a stale name/id after deletion).
+ */
+export function findNamedAgent(
+ agents: readonly NamedAgent[],
+ idOrName: string,
+): NamedAgent | undefined {
+ const lower = idOrName.trim().toLowerCase();
+ return agents.find((a) => a.id === idOrName || a.name.trim().toLowerCase() === lower);
+}
diff --git a/src/features/named-agents/logic/types.ts b/src/features/named-agents/logic/types.ts
new file mode 100644
index 0000000..2e4d4d3
--- /dev/null
+++ b/src/features/named-agents/logic/types.ts
@@ -0,0 +1,57 @@
+/**
+ * Pure shared types for the Named Agents feature — zero DOM, zero effects,
+ * zero Svelte.
+ *
+ * The shared shapes — `NamedAgent`, `NamedAgentSelectionSnapshot`, and the
+ * `/named-agents` HTTP request/response types — are RE-EXPORTED from the
+ * backend-contract packages (`@dispatch/wire`, `@dispatch/transport-contract`),
+ * which now carry them (the backend shipped the named-agents contract). The FE
+ * consumes those packages as the cross-repo seam; only the FE's port/result
+ * types are defined locally.
+ */
+
+import type {
+ CreateNamedAgentRequest,
+ DeleteNamedAgentResponse,
+ NamedAgentResponse,
+ NamedAgentsResponse,
+ UpdateNamedAgentRequest,
+} from "@dispatch/transport-contract";
+import type { NamedAgent, NamedAgentSelectionSnapshot } from "@dispatch/wire";
+
+export type {
+ DeleteNamedAgentResponse,
+ NamedAgent,
+ NamedAgentResponse,
+ NamedAgentSelectionSnapshot,
+ NamedAgentsResponse,
+ UpdateNamedAgentRequest,
+};
+
+/** The subset of a Named Agent a client creates/updates (plan §9 — id is server-minted). */
+export type NamedAgentInput = CreateNamedAgentRequest;
+
+// ── Ports (injected effects — the composition root supplies real responders) ──
+
+/** Outcome of listing Named Agents. */
+export type NamedAgentsResult =
+ | { readonly ok: true; readonly agents: readonly NamedAgent[] }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of creating / updating a Named Agent (the canonical saved record). */
+export type NamedAgentSaveResult =
+ | { readonly ok: true; readonly agent: NamedAgent }
+ | { readonly ok: false; readonly error: string };
+
+/** Outcome of removing a Named Agent. */
+export type NamedAgentDeleteResult =
+ | { readonly ok: true }
+ | { readonly ok: false; readonly error: string };
+
+export type LoadNamedAgents = () => Promise<NamedAgentsResult>;
+export type CreateNamedAgent = (input: NamedAgentInput) => Promise<NamedAgentSaveResult>;
+export type UpdateNamedAgent = (
+ id: string,
+ input: NamedAgentInput,
+) => Promise<NamedAgentSaveResult>;
+export type DeleteNamedAgent = (id: string) => Promise<NamedAgentDeleteResult>;
diff --git a/src/features/named-agents/logic/validate.test.ts b/src/features/named-agents/logic/validate.test.ts
new file mode 100644
index 0000000..d647354
--- /dev/null
+++ b/src/features/named-agents/logic/validate.test.ts
@@ -0,0 +1,58 @@
+import { describe, expect, it } from "vitest";
+import { isUsableModel, isUsableName, nameError, normalizeName } from "./validate";
+
+describe("normalizeName", () => {
+ it("trims surrounding whitespace", () => {
+ expect(normalizeName(" Coding ")).toBe("Coding");
+ });
+
+ it("collapses internal runs of whitespace to one space", () => {
+ expect(normalizeName(" Research Agent ")).toBe("Research Agent");
+ });
+
+ it("collapses an all-whitespace name to empty", () => {
+ expect(normalizeName(" ")).toBe("");
+ });
+});
+
+describe("nameError", () => {
+ it("returns null for a nonempty unique name", () => {
+ expect(nameError("Coding", ["Research", "Fast"])).toBeNull();
+ });
+
+ it("flags an empty/normalized-empty name", () => {
+ expect(nameError("", ["Research"])).toBe("Name is required.");
+ expect(nameError(" ", ["Research"])).toBe("Name is required.");
+ });
+
+ it("flags a name duplicate case-insensitively", () => {
+ expect(nameError("coding", ["Coding"])).toBe("A named agent with this name already exists.");
+ expect(nameError("FAST", ["Fast"])).toBe("A named agent with this name already exists.");
+ });
+
+ it("ignores whitespace when comparing uniqueness", () => {
+ expect(nameError(" coding ", ["Coding "])).toBe(
+ "A named agent with this name already exists.",
+ );
+ });
+});
+
+describe("isUsableName", () => {
+ it("is true only for a unique nonempty name", () => {
+ expect(isUsableName("Coding", ["Research"])).toBe(true);
+ expect(isUsableName("coding", ["Coding"])).toBe(false);
+ expect(isUsableName("", [])).toBe(false);
+ });
+});
+
+describe("isUsableModel", () => {
+ it("accepts a canonical provider/model pair", () => {
+ expect(isUsableModel("opencode-1/claude-sonnet-4.5")).toBe(true);
+ });
+
+ it("rejects a slashless / provider-only value", () => {
+ expect(isUsableModel("opencode-1")).toBe(false);
+ expect(isUsableModel("/claude")).toBe(false);
+ expect(isUsableModel("")).toBe(false);
+ });
+});
diff --git a/src/features/named-agents/logic/validate.ts b/src/features/named-agents/logic/validate.ts
new file mode 100644
index 0000000..c25524a
--- /dev/null
+++ b/src/features/named-agents/logic/validate.ts
@@ -0,0 +1,47 @@
+/**
+ * Pure validation for Named Agent form fields — zero DOM, zero effects.
+ *
+ * A Named Agent is usable only when (plan §5.4): the name is nonempty + unique
+ * case-insensitively, and a provider + model are selected. These pure helpers
+ * drive the card UI's validity gating + inline messages; the backend remains
+ * the authority (it 400s/conflicts on a duplicate or empty name / malformed
+ * model identifier).
+ */
+
+/** Trim + collapse runs of whitespace inside a name (survey-style display). */
+export function normalizeName(raw: string): string {
+ return raw.trim().replace(/\s+/g, " ");
+}
+
+/**
+ * A name-validation error message, or `null` when the name is valid. `otherNames`
+ * are the OTHER agents' names for the case-insensitive uniqueness check (the
+ * caller EXCLUDES the card's own last-persisted name / the card being edited).
+ */
+export function nameError(name: string, otherNames: readonly string[]): string | null {
+ const normalized = normalizeName(name);
+ if (normalized.length === 0) return "Name is required.";
+ const lower = normalized.toLowerCase();
+ for (const other of otherNames) {
+ if (other.trim().toLowerCase() === lower) return "A named agent with this name already exists.";
+ }
+ return null;
+}
+
+/** True iff `name` is a unique, nonempty agent name among `otherNames`. */
+export function isUsableName(name: string, otherNames: readonly string[]): boolean {
+ return nameError(name, otherNames) === null;
+}
+
+/**
+ * Whether a full model identifier is syntactically valid — a canonical
+ * `<provider>/<model>` with a nonempty provider (the part before the first "/")
+ * AND a nonempty model (the part after). A slashless or provider-only value is
+ * invalid for a Named Agent (it must resolve to a concrete model).
+ */
+export function isUsableModel(full: string): boolean {
+ const i = full.indexOf("/");
+ if (i <= 0) return false; // no slash, or empty provider
+ const model = full.slice(i + 1);
+ return model.length > 0;
+}