diff options
Diffstat (limited to 'src/features/named-agents')
17 files changed, 1812 insertions, 0 deletions
diff --git a/src/features/named-agents/index.ts b/src/features/named-agents/index.ts new file mode 100644 index 0000000..e66ef8e --- /dev/null +++ b/src/features/named-agents/index.ts @@ -0,0 +1,64 @@ +export { + type AgentDraft, + type AgentSaved, + type DraftValidity, + draftModel, + draftValidity, + isClean, + isDirty, + type SaveStatus, + saveStatus, + saveStatusLabel, +} from "./logic/auto-save"; +export { + normalizeNamedAgent, + normalizeNamedAgentRecord, + normalizeNamedAgents, + toNamedAgentInput, +} from "./logic/normalize"; +export { + fullModelFromSelection, + joinModel, + modelFromFull, + modelsForProvider, + openCodeNumber, + providerFromModel, + providerLabel, + providerOptions, +} from "./logic/options"; +export { findNamedAgent, snapshotFor } from "./logic/snapshot"; +export type { + CreateNamedAgent, + DeleteNamedAgent, + LoadNamedAgents, + NamedAgent, + NamedAgentDeleteResult, + NamedAgentInput, + NamedAgentResponse, + NamedAgentSaveResult, + NamedAgentSelectionSnapshot, + NamedAgentsResponse, + NamedAgentsResult, + UpdateNamedAgent, +} from "./logic/types"; +export { + isUsableModel, + isUsableName, + nameError, + normalizeName, +} from "./logic/validate"; +export { + type CardController, + type CardControllerOptions, + type CardSaveState, + createCardController, + type NamedAgentPorts, +} from "./ui/controller.svelte"; +export { default as NamedAgentCard } from "./ui/NamedAgentCard.svelte"; +export { default as NamedAgentsView } from "./ui/NamedAgentsView.svelte"; + +/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */ +export const manifest = { + name: "named-agents", + description: "Curated Named Agent presets (name + model) in the sidebar", +} as const; 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; +} diff --git a/src/features/named-agents/ui/NamedAgentCard.svelte b/src/features/named-agents/ui/NamedAgentCard.svelte new file mode 100644 index 0000000..b81c59c --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentCard.svelte @@ -0,0 +1,120 @@ +<script lang="ts"> + import type { NamedAgent } from "../logic/types"; + import type { CardController } from "./controller.svelte"; + import { + modelsForProvider, + providerLabel, + providerOptions, + fullModelFromSelection, + } from "../logic/options"; + import { draftValidity } from "../logic/auto-save"; + import { saveStatusLabel } from "../logic/auto-save"; + + let { + models, + agent, + controller, + otherNames, + }: { + /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */ + models: readonly string[]; + /** The persisted agent this card edits, or null for a new draft. */ + agent: NamedAgent | null; + /** The card's auto-save controller (draft + status + save lifecycle). */ + controller: CardController; + /** The OTHER agents' names (excludes this card) for the uniqueness hint. */ + otherNames: readonly string[]; + } = $props(); + + const providers = $derived(providerOptions(models)); + const providerModels = $derived(modelsForProvider(models, controller.draft.provider)); + // The full model string a provider+model selection maps to (for the "unavailable" + // fallback when the saved model leaves the catalog). + const fullModel = $derived( + fullModelFromSelection(controller.draft.provider, controller.draft.model), + ); + const validity = $derived(draftValidity(controller.draft, otherNames)); + const statusLabel = $derived(saveStatusLabel(controller.save.status)); + // Whether the current provider+model actually resolves inside the catalog. + const selectedResolves = $derived( + controller.draft.provider !== "" && + modelsForProvider(models, controller.draft.provider).includes(controller.draft.model), + ); +</script> + +<div class="flex flex-col gap-1 rounded-box bg-base-200 p-3"> + <span class="text-[10px] uppercase opacity-60">Name</span> + <input + type="text" + class="input input-bordered input-sm w-full" + placeholder="Enter a name…" + aria-label="Named agent name" + value={controller.draft.name} + oninput={(e) => controller.setName(e.currentTarget.value)} + /> + + <span class="mt-1 text-[10px] uppercase opacity-60">Provider</span> + <select + class="select select-bordered select-sm w-full" + aria-label="Named agent provider" + value={controller.draft.provider} + onchange={(e) => controller.setProvider(e.currentTarget.value)} + > + {#if controller.draft.provider === ""} + <option value="" disabled>Select provider…</option> + {/if} + {#each providers as provider (provider)} + <option value={provider}>{providerLabel(provider)}</option> + {/each} + </select> + + <span class="mt-1 text-[10px] uppercase opacity-60">Model</span> + <select + class="select select-bordered select-sm w-full" + aria-label="Named agent model" + value={controller.draft.model} + disabled={controller.draft.provider === ""} + onchange={(e) => controller.setModel(e.currentTarget.value)} + > + {#if controller.draft.provider === ""} + <option value="" disabled>Select provider first</option> + {:else if controller.draft.model === ""} + <option value="" disabled>Select a model…</option> + {/if} + {#each providerModels as model (model)} + <option value={model}>{model}</option> + {/each} + {#if !selectedResolves && controller.draft.model !== ""} + <!-- The saved selection is no longer in the live catalog (its provider/ + credential was removed) — keep showing it as unavailable rather than + silently rewriting it. --> + <option value={controller.draft.model} disabled> + {fullModel} (unavailable) + </option> + {/if} + </select> + + {#if !validity.usable && validity.message !== null} + <p class="mt-1 text-xs text-warning" role="status">{validity.message}</p> + {/if} + + <div class="mt-2 flex items-center justify-between gap-2"> + <span + class="text-xs" + class:text-error={controller.save.status === "error" && controller.save.error !== null} + class:opacity-60={controller.save.status !== "saving" && controller.save.status !== "error"} + role="status" + > + {statusLabel}{#if controller.save.error !== null}: {controller.save.error}{/if} + </span> + <button + type="button" + class="btn btn-ghost btn-xs text-error" + aria-label="Remove named agent" + disabled={controller.saving} + onclick={() => controller.remove()} + > + Remove + </button> + </div> +</div>
\ No newline at end of file diff --git a/src/features/named-agents/ui/NamedAgentsView.svelte b/src/features/named-agents/ui/NamedAgentsView.svelte new file mode 100644 index 0000000..743ac20 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.svelte @@ -0,0 +1,126 @@ +<script lang="ts"> + import { untrack } from "svelte"; + import type { NamedAgent } from "../logic/types"; + import type { NamedAgentPorts } from "./controller.svelte"; + import { createCardController, type CardController } from "./controller.svelte"; + import NamedAgentCard from "./NamedAgentCard.svelte"; + + interface Row { + key: number; + agent: NamedAgent | null; + controller: CardController; + } + + let { + agents, + models, + ports, + }: { + /** The persisted Named Agents (seed for the cards; the store stays authoritative for consumers). */ + agents: readonly NamedAgent[]; + /** Available models (`<provider>/<model>`) — the source of the provider/model dropdowns. */ + models: readonly string[]; + ports: NamedAgentPorts; + } = $props(); + + // The view OWNS the editable card rows (each a controller + its last-saved + // canonical agent). Seeded once from the `agents` snapshot on mount (untracked + // so a later store update — which this view drove — never clobbers an + // in-flight draft). The store remains authoritative for other consumers. + let rows = $state<Row[]>([]); + let nextKey = $state(0); + let seeded = $state(false); + + $effect(() => { + untrack(() => { + if (seeded) return; + seeded = true; + for (const a of agents) addRow(a); + }); + }); + + /** + * The other rows' names (excluding the row with `rowKey`) for the uniqueness + * hint. Identity is the ROW's stable `key` — a draft (`agent === null`) must + * still exclude ITSELF (otherwise a fresh draft collides with its own name), + * while any OTHER draft is a genuine sibling. + */ + function otherNamesOf(rowKey: number): readonly string[] { + const out: string[] = []; + for (const r of rows) { + if (r.key === rowKey) continue; + out.push(r.agent !== null ? r.agent.name : r.controller.draft.name); + } + return out; + } + + /** + * Live uniqueness predicate for a card: true when `name` collides with a + * SIBLING row (excludes the card's own row by its stable `rowKey` — not by a + * nullable persisted id, so a draft excludes itself exactly once). + */ + function isNameTakenFor(rowKey: number): (name: string) => boolean { + return (name: string): boolean => { + const lower = name.trim().toLowerCase(); + for (const r of rows) { + if (r.key === rowKey) continue; + const candidate = r.agent !== null ? r.agent.name : r.controller.draft.name; + if (candidate.trim().toLowerCase() === lower) return true; + } + return false; + }; + } + + function addRow(agent: NamedAgent | null): void { + // Capture the row's stable key FIRST so the controller's callbacks reconcile + // THIS row only — never a sibling draft (`agent === null` matches all). + const key = nextKey++; + const controller = createCardController({ + agent, + savedModel: agent?.model ?? "", + isNameTaken: isNameTakenFor(key), + ports, + onChanged: (saved) => replaceAgent(key, saved), + onRemoved: () => removeRow(key), + }); + rows = [...rows, { key, agent, controller }]; + } + + function replaceAgent(key: number, saved: NamedAgent): void { + rows = rows.map((r) => (r.key === key ? { ...r, agent: saved } : r)); + } + + function removeRow(key: number): void { + const row = rows.find((r) => r.key === key); + if (row === undefined) return; + row.controller.dispose(); + rows = rows.filter((r) => r.key !== key); + } + + function addDraft(): void { + addRow(null); + } +</script> + +<div class="flex flex-col gap-2"> + <ul class="flex flex-col gap-2"> + {#each rows as row (row.key)} + <li> + <NamedAgentCard + models={models} + agent={row.agent} + controller={row.controller} + otherNames={otherNamesOf(row.key)} + /> + </li> + {/each} + </ul> + + {#if rows.length === 0} + <p class="text-xs opacity-60">No named agents yet.</p> + {/if} + + <button type="button" class="btn btn-ghost btn-xs w-fit" onclick={addDraft}> + + Add Named Agent + </button> +</div>
\ No newline at end of file diff --git a/src/features/named-agents/ui/NamedAgentsView.test.ts b/src/features/named-agents/ui/NamedAgentsView.test.ts new file mode 100644 index 0000000..bac4e25 --- /dev/null +++ b/src/features/named-agents/ui/NamedAgentsView.test.ts @@ -0,0 +1,181 @@ +import { render, screen } from "@testing-library/svelte"; +import type { UserEvent } from "@testing-library/user-event"; +import userEvent from "@testing-library/user-event"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { + CreateNamedAgent, + DeleteNamedAgent, + NamedAgentInput, + UpdateNamedAgent, +} from "../logic/types"; +import type { NamedAgentPorts } from "./controller.svelte"; +import NamedAgentsView from "./NamedAgentsView.svelte"; + +// ── Fakes for the injected ports (ONLY the outermost edge — no sibling module +// is mocked). A modest `debounceMs` keeps name edits coalesced into one save +// (with 0, each keystroke fires its own update) while provider/model saves stay +// immediate (the model setter dispatches synchronously). +function makePorts(): { + ports: NamedAgentPorts; + created: NamedAgentInput[]; + updated: { id: string; input: NamedAgentInput }[]; + removed: string[]; +} { + const created: NamedAgentInput[] = []; + const updated: { id: string; input: NamedAgentInput }[] = []; + const removed: string[] = []; + const ports: NamedAgentPorts = { + create: (async (input) => { + created.push(input); + return { ok: true, agent: { id: `new-${created.length}`, ...input } }; + }) as CreateNamedAgent, + update: (async (id, input) => { + updated.push({ id, input }); + return { ok: true, agent: { id, ...input } }; + }) as UpdateNamedAgent, + remove: (async (id) => { + removed.push(id); + return { ok: true } as const; + }) as DeleteNamedAgent, + debounceMs: 150, + }; + return { ports, created, updated, removed }; +} + +const MODELS = ["opencode-1/claude-sonnet-4.5", "anthropic/sonnet"] as unknown as readonly string[]; + +const DUP_MESSAGE = "A named agent with this name already exists."; + +function renderView() { + const fakes = makePorts(); + render(NamedAgentsView, { props: { agents: [], models: MODELS, ports: fakes.ports } }); + return fakes; +} + +async function addDraft(user: UserEvent): Promise<void> { + await user.click(screen.getByRole("button", { name: "+ Add Named Agent" })); +} + +async function typeName(index: number, name: string): Promise<void> { + const inputs = screen.getAllByLabelText("Named agent name"); + const input = inputs[index]; + if (input === undefined) throw new Error(`No named-agent name input at index ${index}`); + await userEvent.setup().clear(input); + await userEvent.setup().type(input, name); +} + +async function completeDraft(index: number): Promise<void> { + const user = userEvent.setup(); + // Provider first (the model select is disabled until a provider is chosen). + const providerSelect = screen.getAllByLabelText("Named agent provider")[index]; + if (providerSelect === undefined) throw new Error(`No provider select at index ${index}`); + await user.selectOptions(providerSelect, "opencode-1"); + const modelSelect = screen.getAllByLabelText("Named agent model")[index]; + if (modelSelect === undefined) throw new Error(`No model select at index ${index}`); + await user.selectOptions(modelSelect, "claude-sonnet-4.5"); +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("NamedAgentsView", () => { + it("a fresh single draft does not self-collide with its own name", async () => { + renderView(); + await addDraft(userEvent.setup()); + await typeName(0, "Coding Agent"); + + // The card is not yet usable (no model) so it shows a model hint, but it + // must NOT report its own draft name as an existing duplicate. + await screen.findByText("Select a provider."); + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + }); + + it("two drafts with DISTINCT names neither self-collide", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Each must see the OTHER's name as an other-name, never its OWN. + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + }); + + it("two drafts with the SAME name are a genuine sibling collision (each flags it)", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Coding Agent"); + + // Duplicate names among DISTINCT rows are invalid — both rows must report it + // (the self is excluded, but the sibling's identical name is a real collision). + expect((await screen.findAllByText(DUP_MESSAGE)).length).toBe(2); + }); + + it("saving one of multiple drafts persists only that row (distinct records)", async () => { + const user = userEvent.setup(); + const fakes = renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Both drafts complete to usable cards → each dispatches its OWN create. + await completeDraft(0); + await completeDraft(1); + + await vi.waitFor(() => expect(fakes.created).toHaveLength(2)); + expect(fakes.created[0]?.name).toBe("Coding Agent"); + expect(fakes.created[1]?.name).toBe("Writer"); + // Each persisted record got its own server-minted id (a single draft must + // not clobber its sibling's reconciling to the same record). + expect(await screen.findAllByText("Saved")).toHaveLength(2); + }); + + it("after a draft persists, a sibling draft does not self-collide and the saved row re-saves on edit", async () => { + const user = userEvent.setup(); + const fakes = renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await completeDraft(0); + await vi.waitFor(() => expect(fakes.created).toHaveLength(1)); + + // Add a second draft with a DIFFERENT name. After row 0 is persisted, this + // still-draft row must not treat its own name as a duplicate. + await addDraft(user); + await typeName(1, "Writer"); + expect(screen.queryByText(DUP_MESSAGE)).toBeNull(); + + // The saved card's controller now owns a real id → a later edit targets an + // UPDATE of the SAME row (id new-1), never a fresh create. + await typeName(0, "Coding Agent X"); + await vi.waitFor(() => expect(fakes.updated.length).toBeGreaterThanOrEqual(1)); + expect(fakes.updated[0]?.id).toBe("new-1"); + expect(fakes.updated[0]?.input.name).toBe("Coding Agent X"); + // Still exactly one create — the edit reconciled, not re-created. + expect(fakes.created).toHaveLength(1); + }); + + it("removing one of multiple drafts removes only that row", async () => { + const user = userEvent.setup(); + renderView(); + await addDraft(user); + await typeName(0, "Coding Agent"); + await addDraft(user); + await typeName(1, "Writer"); + + // Remove the SECOND card — the first must survive. + const removeButtons = screen.getAllByRole("button", { name: "Remove named agent" }); + const second = removeButtons[1]; + if (second === undefined) throw new Error("Expected a second Remove button"); + await user.click(second); + + const inputs = await screen.findAllByLabelText("Named agent name"); + expect(inputs).toHaveLength(1); + expect((inputs[0] as HTMLInputElement).value).toBe("Coding Agent"); + }); +}); diff --git a/src/features/named-agents/ui/controller.svelte.ts b/src/features/named-agents/ui/controller.svelte.ts new file mode 100644 index 0000000..51c85b0 --- /dev/null +++ b/src/features/named-agents/ui/controller.svelte.ts @@ -0,0 +1,257 @@ +// Injected shell for Named Agent auto-save: owns the reactive per-card editing +// + save lifecycle (the thin rune wrapper over `logic/auto-save`), the debounce +// timer for name edits, and the monotonic sequence guard that stops an older +// save response clobbering a newer edit. No ambient state: ONE controller per +// card, created by the view and disposed on unmount. The network calls +// (create/update/delete) are INJECTED ports — this module never touches +// fetch/DOM. +// +// The pure model (`logic/auto-save`) does the status/validity reasoning; this +// controller only owns the effectful plumbing + the sequence guard. A live +// `isNameTaken` callback (supplied by the owning view against the CURRENT +// sibling list) keeps the controller from persisting a duplicate name. + +import { + type AgentDraft, + type AgentSaved, + draftModel, + type SaveStatus, + saveStatus, +} from "../logic/auto-save"; +import type { + CreateNamedAgent, + DeleteNamedAgent, + NamedAgent, + NamedAgentInput, + UpdateNamedAgent, +} from "../logic/types"; +import { isUsableModel, normalizeName } from "../logic/validate"; + +/** Named-agent edit port bundle (injected by the composition root). */ +export interface NamedAgentPorts { + create: CreateNamedAgent; + update: UpdateNamedAgent; + remove: DeleteNamedAgent; + /** Debounce delay (ms) for NAME edits only (provider/model saves are immediate). */ + debounceMs?: number; +} + +export interface CardSaveState { + /** The current reactive save status (drives the status line). */ + readonly status: SaveStatus; + /** A save-failure error message, or null. */ + readonly error: string | null; +} + +/** A fully-wired Named Agent card controller (one per card row). */ +export interface CardController { + /** The current reactive status + error. */ + readonly save: CardSaveState; + /** Read the current draft (const snapshots already applied via the setter methods). */ + readonly draft: AgentDraft; + /** Whether the card is currently awaiting a save response. */ + readonly saving: boolean; + /** Bind a provider id into the draft (immediate save once a model is chosen). */ + setProvider(provider: string): void; + /** Bind a model suffix under the current provider (immediate save). */ + setModel(model: string): void; + /** Bind a raw name edit (debounced save when it becomes usable). */ + setName(name: string): void; + /** Remove the Named Agent (for an existing card) — notifies via `onRemoved`. */ + remove(): void; + /** Dispose timers; call on unmount. */ + dispose(): void; +} + +export interface CardControllerOptions { + /** The existing Named Agent this card edits, or null for a new draft. */ + agent: NamedAgent | null; + /** The saved full `<provider>/<model>` ('' for a new draft). */ + savedModel: string; + /** + * Live uniqueness predicate: true when `name` is already taken by a SIBLING + * card (the view EXCLUDES this card's own id). Drives the controller's + * usable-name gate so a duplicate is never persisted. + */ + isNameTaken: (name: string) => boolean; + ports: NamedAgentPorts; + /** Fired with the canonical saved record when a create/update settles. */ + onChanged: (agent: NamedAgent) => void; + /** Fired when the card should leave the list (after a successful remove, or an unsaved draft). */ + onRemoved: () => void; +} + +/** + * A card controller bound to one Named Agent (existing) or a new draft. + * + * The controller owns ONLY this card's draft + save lifecycle; the sibling list + * (for uniqueness + reconciling the authoritative list after a save) is the + * parent view's. `onChanged` lets the parent replace the card's canonical record + * on success; `onRemoved` fires when the card is to be dropped. + */ +export function createCardController(opts: CardControllerOptions): CardController { + const debounceMs = opts.ports.debounceMs ?? 400; + + // ── The draft: raw form controls (name/provider/model) owned reactively. ── + const { agent, savedModel } = opts; + let id = $state<string | null>(agent?.id ?? null); + let name = $state<string>(agent?.name ?? ""); + let provider = $state<string>(savedModel === "" ? "" : (savedModel.split("/")[0] ?? "")); + let model = $state<string>( + savedModel === "" ? "" : savedModel.slice(savedModel.indexOf("/") + 1), + ); + + // ── Saved-state + in-flight/sequence tracking. ── + // `savedName`/`savedModelValue` = the last PERSISTED definition; `error` = + // last save failure. `editSeq` increments on every input change; a save + // captures the seq at dispatch time and applies its result ONLY if it is still + // the latest — an older response can never overwrite a newer edit (§5.4). + let savedName = $state<string>(agent?.name ?? ""); + let savedModelValue = $state<string>(savedModel); + let saving = $state(false); + let error = $state<string | null>(null); + + let editSeq = 0; + let nameTimer: ReturnType<typeof setTimeout> | null = null; + + function currentDraft(): AgentDraft { + return { id, name, provider, model }; + } + + function bump(): number { + return ++editSeq; + } + + function nameUsable(): boolean { + // A usable name is nonempty + not already taken by a sibling card. + const normalized = normalizeName(name); + return normalized !== "" && !opts.isNameTaken(normalized); + } + + function usable(full: string): boolean { + return nameUsable() && isUsableModel(full); + } + + // A save is owed + legal to dispatch right now when the card is usable and + // differs from the persisted definition (or is a fresh, usable draft). + function shouldSaveNow(): boolean { + const d = currentDraft(); + const full = draftModel(d); + if (!usable(full)) return false; + if (id === null && savedName === "" && savedModelValue === "") return true; // fresh draft + return normalizeName(d.name) !== savedName || full !== savedModelValue; + } + + function doSave(seq: number): void { + const d = currentDraft(); + const full = draftModel(d); + if (!usable(full)) return; + saving = true; + error = null; + + function settle(ok: boolean, saved?: NamedAgent, err?: string): void { + // Stale-response guard: only apply if this is still the latest edit. + if (seq !== editSeq) return; + saving = false; + if (ok && saved) { + savedName = saved.name; + savedModelValue = saved.model; + id = saved.id; + opts.onChanged(saved); + } else { + error = err ?? "Save failed"; + } + } + + const input: NamedAgentInput = { name: normalizeName(d.name), model: full }; + if (id === null) { + void opts.ports.create(input).then( + (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)), + (e) => settle(false, undefined, String(e)), + ); + } else { + void opts.ports.update(id, input).then( + (r) => (r.ok ? settle(true, r.agent) : settle(false, undefined, r.error)), + (e) => settle(false, undefined, String(e)), + ); + } + } + + function scheduleNameSave(): void { + if (nameTimer !== null) clearTimeout(nameTimer); + const seq = editSeq; + nameTimer = setTimeout(() => { + nameTimer = null; + if (shouldSaveNow()) doSave(seq); + }, debounceMs); + } + + function saveImmediately(): void { + if (nameTimer !== null) { + clearTimeout(nameTimer); + nameTimer = null; + } + const seq = editSeq; + if (shouldSaveNow()) doSave(seq); + } + + // Reactive derived status: from the current draft + saved + in-flight + error, + // via the PURE `saveStatus` (this shell supplies only the orchestration inputs). + const save = $derived.by<CardSaveState>(() => { + const d = currentDraft(); + const saved: AgentSaved = { id, name: savedName, model: savedModelValue }; + return { status: saveStatus(d, saved, saving, error), error }; + }); + + return { + get save(): CardSaveState { + return save; + }, + get draft(): AgentDraft { + return currentDraft(); + }, + get saving(): boolean { + return saving; + }, + setProvider(next: string): void { + bump(); + provider = next; + // Changing provider clears the prior model if it may not belong to the new + // one; the model dropdown re-populates from the new provider. Persist only + // once a valid model is selected (§5.5) → the model change saves immediately. + model = ""; + }, + setModel(next: string): void { + bump(); + model = next; + saveImmediately(); + }, + setName(next: string): void { + bump(); + name = next; + scheduleNameSave(); + }, + remove(): void { + if (id === null) { + // An unsaved draft — nothing server-side to delete. + opts.onRemoved(); + return; + } + saving = true; + void opts.ports.remove(id).then( + () => { + saving = false; + opts.onRemoved(); + }, + (e) => { + saving = false; + error = String(e); + }, + ); + }, + dispose(): void { + if (nameTimer !== null) clearTimeout(nameTimer); + nameTimer = null; + }, + }; +} diff --git a/src/features/named-agents/ui/controller.test.ts b/src/features/named-agents/ui/controller.test.ts new file mode 100644 index 0000000..1655a31 --- /dev/null +++ b/src/features/named-agents/ui/controller.test.ts @@ -0,0 +1,237 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import type { NamedAgent, NamedAgentInput, NamedAgentSaveResult } from "../logic/types"; +import { createCardController, type NamedAgentPorts } from "./controller.svelte"; + +function fakePorts(overrides?: { + create?: NamedAgentPorts["create"]; + update?: NamedAgentPorts["update"]; + remove?: NamedAgentPorts["remove"]; + debounceMs?: number; +}): { + ports: NamedAgentPorts; + created: NamedAgentInput[]; + updated: Array<{ id: string; input: NamedAgentInput }>; + removed: string[]; +} { + const created: NamedAgentInput[] = []; + const updated: Array<{ id: string; input: NamedAgentInput }> = []; + const removed: string[] = []; + const settle = (r: NamedAgentSaveResult) => Promise.resolve(r); + return { + ports: { + create: + overrides?.create ?? + ((input) => { + created.push(input); + return settle({ ok: true, agent: { id: `new-${created.length}`, ...input } }); + }), + update: + overrides?.update ?? + ((id, input) => { + updated.push({ id, input }); + return settle({ ok: true, agent: { id, ...input } }); + }), + remove: + overrides?.remove ?? + ((id) => { + removed.push(id); + return Promise.resolve({ ok: true } as const); + }), + debounceMs: overrides?.debounceMs ?? 0, + } as NamedAgentPorts, + created, + updated, + removed, + }; +} + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("createCardController", () => { + it("creates a new draft when it becomes fully usable and the model is set", async () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const changed: NamedAgent[] = []; + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: (a) => changed.push(a), + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + c.setProvider("opencode-1"); + c.setModel("claude-sonnet-4.5"); + // The model setter saves immediately (the create is dispatched synchronously). + expect(created).toHaveLength(1); + expect(created[0]).toEqual({ name: "Coding Agent", model: "opencode-1/claude-sonnet-4.5" }); + // The canonical record + onChanged land after the create promise resolves. + await vi.runAllTimersAsync(); + expect(changed).toHaveLength(1); + expect(changed[0]?.id).toBe("new-1"); + c.dispose(); + }); + + it("does not create with only a name (needs a provider + model)", () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + vi.advanceTimersByTime(500); + expect(created).toHaveLength(0); + c.dispose(); + }); + + it("does not persist a name that is already taken", () => { + vi.useFakeTimers(); + const { ports, created } = fakePorts(); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: (name) => name.toLowerCase() === "coding agent", + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding Agent"); + c.setProvider("opencode-1"); + c.setModel("claude-sonnet-4.5"); + expect(created).toHaveLength(0); + c.dispose(); + }); + + it("updates an existing card and reports Saved once clean", async () => { + vi.useFakeTimers(); + const existing: NamedAgent = { + id: "a1", + name: "Coding", + model: "opencode-1/claude-sonnet-4.5", + }; + const { ports, updated } = fakePorts({ debounceMs: 100 }); + const c = createCardController({ + agent: existing, + savedModel: existing.model, + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + expect(c.save.status).toBe("saved"); + c.setName("Coding X"); + // Within the debounce window the edit is unsaved → "dirty". + await vi.advanceTimersByTimeAsync(20); + expect(c.save.status).toBe("dirty"); + // Past the debounce the name save fires → the update is dispatched. + await vi.advanceTimersByTimeAsync(200); + expect(updated).toHaveLength(1); + expect(updated[0]?.id).toBe("a1"); + expect(updated[0]?.input.name).toBe("Coding X"); + // After the update promise resolves the card reads clean → "saved". + await vi.runAllTimersAsync(); + expect(c.save.status).toBe("saved"); + c.dispose(); + }); + + it("marks the status as error and keeps the draft on save failure", async () => { + vi.useFakeTimers(); + const { ports } = fakePorts({ + create: () => Promise.resolve({ ok: false, error: "duplicate name" as const }), + }); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + c.setName("Coding"); + c.setProvider("opencode-1"); + c.setModel("claude"); + await vi.runAllTimersAsync(); + expect(c.save.status).toBe("error"); + expect(c.save.error).toBe("duplicate name"); + expect(c.draft.name).toBe("Coding"); + c.dispose(); + }); + + it("guards stale responses: a newer edit is not overwritten by an older save result", async () => { + vi.useFakeTimers(); + const pending: { resolve: ((r: NamedAgentSaveResult) => void) | null } = { resolve: null }; + const { ports } = fakePorts({ + create: () => new Promise<NamedAgentSaveResult>((res) => (pending.resolve = res)), + }); + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => {}, + }); + // Kick a save in flight, then edit again before it resolves. + c.setName("A"); + c.setProvider("opencode-1"); + c.setModel("claude"); + c.setName("B"); // sequence bumps past the in-flight save + expect(pending.resolve).not.toBeNull(); + pending.resolve?.({ ok: true, agent: { id: "x", name: "A", model: "opencode-1/claude" } }); + await vi.runAllTimersAsync(); + // The stale "A" create must NOT have applied the old result (saving cleared, + // but the draft still reads B and no onChanged with "A"). + expect(c.draft.name).toBe("B"); + c.dispose(); + }); + + it("remove() on an existing card calls remove and drops via onRemoved", async () => { + vi.useFakeTimers(); + const existing: NamedAgent = { + id: "a1", + name: "Coding", + model: "opencode-1/claude", + }; + const { ports, removed } = fakePorts(); + let removedCard = false; + const c = createCardController({ + agent: existing, + savedModel: existing.model, + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => (removedCard = true), + }); + c.remove(); + await vi.runAllTimersAsync(); + expect(removed).toEqual(["a1"]); + expect(removedCard).toBe(true); + c.dispose(); + }); + + it("remove() on an unsaved draft drops it without a server call", () => { + vi.useFakeTimers(); + const { ports, removed } = fakePorts(); + let removedCard = false; + const c = createCardController({ + agent: null, + savedModel: "", + isNameTaken: () => false, + ports, + onChanged: () => {}, + onRemoved: () => (removedCard = true), + }); + c.remove(); + expect(removed).toHaveLength(0); + expect(removedCard).toBe(true); + c.dispose(); + }); +}); |
