From fb7eb3105e1fbbdefdad9348f9132815b5bbe52d Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 20 Aug 2026 19:36:12 +0900 Subject: feat(named-agents): sidebar view, synthetic provider, auto-save, snapshot semantics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/features/named-agents/logic/auto-save.test.ts | 157 ++++++++++++++++++++++ src/features/named-agents/logic/auto-save.ts | 138 +++++++++++++++++++ src/features/named-agents/logic/normalize.test.ts | 78 +++++++++++ src/features/named-agents/logic/normalize.ts | 68 ++++++++++ src/features/named-agents/logic/options.test.ts | 72 ++++++++++ src/features/named-agents/logic/options.ts | 84 ++++++++++++ src/features/named-agents/logic/snapshot.test.ts | 35 +++++ src/features/named-agents/logic/snapshot.ts | 33 +++++ src/features/named-agents/logic/types.ts | 57 ++++++++ src/features/named-agents/logic/validate.test.ts | 58 ++++++++ src/features/named-agents/logic/validate.ts | 47 +++++++ 11 files changed, 827 insertions(+) create mode 100644 src/features/named-agents/logic/auto-save.test.ts create mode 100644 src/features/named-agents/logic/auto-save.ts create mode 100644 src/features/named-agents/logic/normalize.test.ts create mode 100644 src/features/named-agents/logic/normalize.ts create mode 100644 src/features/named-agents/logic/options.test.ts create mode 100644 src/features/named-agents/logic/options.ts create mode 100644 src/features/named-agents/logic/snapshot.test.ts create mode 100644 src/features/named-agents/logic/snapshot.ts create mode 100644 src/features/named-agents/logic/types.ts create mode 100644 src/features/named-agents/logic/validate.test.ts create mode 100644 src/features/named-agents/logic/validate.ts (limited to 'src/features/named-agents/logic') 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 => ({ + 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 `/` name a draft would persist. */ +export function draftModel(draft: AgentDraft): string { + return draft.model === "" ? draft.provider : `${draft.provider}/${draft.model}`; +} + +/** The full `/` 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 { + 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 / 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 `/` + * 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 `/` 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 `/` 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 `/` 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(); + 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-` → "OpenCode #"; + * 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; +export type CreateNamedAgent = (input: NamedAgentInput) => Promise; +export type UpdateNamedAgent = ( + id: string, + input: NamedAgentInput, +) => Promise; +export type DeleteNamedAgent = (id: string) => Promise; 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 + * `/` 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; +} -- cgit v1.2.3