summaryrefslogtreecommitdiffhomepage
path: root/src/features/named-agents/logic/auto-save.test.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
committerAdam Malczewski <[email protected]>2026-08-20 19:36:12 +0900
commitfb7eb3105e1fbbdefdad9348f9132815b5bbe52d (patch)
treedeb95e97a917424ea67470b168b91349a41d1c08 /src/features/named-agents/logic/auto-save.test.ts
parent63588c3c69b32d180dc78a2cc187cc8aa22ad7e0 (diff)
downloaddispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.tar.gz
dispatch-web-fb7eb3105e1fbbdefdad9348f9132815b5bbe52d.zip
feat(named-agents): sidebar view, synthetic provider, auto-save, snapshot semantics
Implement the frontend of the Named Agents feature (plan §5-§9): - New features/named-agents module: pure logic (types re-exported from @dispatch/wire + transport-contract, validation, provider/model options, snapshot helpers, network normalizers, auto-save status model) + an injected auto-save controller (name debounce, immediate provider/model save, monotonic stale-response guard) + the collapsible sidebar card view. - Store: /named-agents CRUD + boot seed; selectModel(model, snapshot?) persists the raw model WITH the namedAgent snapshot; draft promotion carries it; refreshModel restores ModelResponse.namedAgent. - Chat store setNamedAgent (sends namedAgent on chat.send); Tab.snapshot + setSnapshot (retained across rename/remove, persisted with tabs). - Model selector: synthetic Named Agents provider ALWAYS first; selecting one resolves to the raw model + records a snapshot; a retained snapshot of a renamed/removed agent still displays as unavailable (plan §7). - Composition root wires the view (viewKinds + loaded-modules manifest) + feeds the selector. Tests: logic + controller + selector + app-store CRUD (1246 pass), typecheck 0, biome clean (only pre-existing config infos pending a future migrate), build OK.
Diffstat (limited to 'src/features/named-agents/logic/auto-save.test.ts')
-rw-r--r--src/features/named-agents/logic/auto-save.test.ts157
1 files changed, 157 insertions, 0 deletions
diff --git a/src/features/named-agents/logic/auto-save.test.ts b/src/features/named-agents/logic/auto-save.test.ts
new file mode 100644
index 0000000..24c9571
--- /dev/null
+++ b/src/features/named-agents/logic/auto-save.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, it } from "vitest";
+import {
+ type AgentDraft,
+ draftModel,
+ draftValidity,
+ isDirty,
+ saveStatus,
+ saveStatusLabel,
+} from "./auto-save";
+
+const draft = (p: Partial<AgentDraft>): AgentDraft => ({
+ id: null,
+ name: "",
+ provider: "",
+ model: "",
+ ...p,
+});
+
+describe("draftModel", () => {
+ it("joins provider + model suffix", () => {
+ expect(draftModel(draft({ provider: "opencode-1", model: "claude" }))).toBe(
+ "opencode-1/claude",
+ );
+ });
+
+ it("falls back to provider-only when no model", () => {
+ expect(draftModel(draft({ provider: "opencode-1" }))).toBe("opencode-1");
+ });
+});
+
+describe("draftValidity", () => {
+ it("is usable when name, provider, and model are all valid", () => {
+ const v = draftValidity(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), []);
+ expect(v.usable).toBe(true);
+ expect(v.message).toBeNull();
+ });
+
+ it("is unusable with an empty name / duplicate / missing model", () => {
+ expect(draftValidity(draft({ name: "", provider: "p", model: "m" }), []).usable).toBe(false);
+ expect(
+ draftValidity(draft({ name: "coding", provider: "p", model: "m" }), ["Coding"]).usable,
+ ).toBe(false);
+ expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).usable).toBe(
+ false,
+ );
+ });
+
+ it("reports the first invalid field's message", () => {
+ expect(draftValidity(draft({ name: "", provider: "", model: "" }), []).message).toBe(
+ "Name is required.",
+ );
+ expect(
+ draftValidity(draft({ name: "coding", provider: "", model: "" }), ["Coding"]).message,
+ ).toBe("A named agent with this name already exists.");
+ expect(draftValidity(draft({ name: "Coding", provider: "", model: "" }), []).message).toBe(
+ "Select a provider.",
+ );
+ });
+});
+
+describe("isDirty", () => {
+ it("is false when the draft matches the saved definition", () => {
+ expect(
+ isDirty(draft({ name: "Coding", provider: "opencode-1", model: "claude" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(false);
+ });
+
+ it("is true when name or model differ", () => {
+ expect(
+ isDirty(draft({ name: "Coding X", provider: "opencode-1", model: "claude" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ expect(
+ isDirty(draft({ name: "Coding", provider: "umans", model: "glm" }), {
+ id: "a",
+ name: "Coding",
+ model: "opencode-1/claude",
+ }),
+ ).toBe(true);
+ });
+});
+
+describe("saveStatus", () => {
+ const saved = { id: "a", name: "Coding", model: "opencode-1/claude" };
+
+ it("is saved when clean with an id", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ null,
+ ),
+ ).toBe("saved");
+ });
+
+ it("is dirty when input diverges from the saved definition", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding X", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ null,
+ ),
+ ).toBe("dirty");
+ });
+
+ it("is an idle draft when it has no id", () => {
+ expect(
+ saveStatus(
+ draft({ name: "Coding", provider: "opencode-1", model: "claude" }),
+ { id: null, name: "", model: "" },
+ false,
+ null,
+ ),
+ ).toBe("idle");
+ });
+
+ it("is saving while a save is in flight", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ true,
+ null,
+ ),
+ ).toBe("saving");
+ });
+
+ it("reports an error when the last save failed", () => {
+ expect(
+ saveStatus(
+ draft({ id: "a", name: "Coding", provider: "opencode-1", model: "claude" }),
+ saved,
+ false,
+ "boom",
+ ),
+ ).toBe("error");
+ });
+});
+
+describe("saveStatusLabel", () => {
+ it("maps every status to a short label", () => {
+ expect(saveStatusLabel("idle")).toBe("Not saved");
+ expect(saveStatusLabel("dirty")).toBe("Not saved");
+ expect(saveStatusLabel("saving")).toBe("Saving…");
+ expect(saveStatusLabel("saved")).toBe("Saved");
+ expect(saveStatusLabel("error")).toBe("Save failed");
+ });
+});