diff --git a/src/application/components/agent/SkillMemorySelects.tsx b/src/application/components/agent/SkillMemorySelects.tsx
new file mode 100644
index 0000000..62ccf31
--- /dev/null
+++ b/src/application/components/agent/SkillMemorySelects.tsx
@@ -0,0 +1,107 @@
+import { useMemo } from "react";
+import { useQueries } from "@tanstack/react-query";
+import { useStoreFiles } from "@/application/hooks/store/useStoreFiles";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+import { parseFrontmatter } from "@/application/lib/frontmatter";
+import PillMultiSelect, {
+ type PillMultiSelectOption,
+} from "@/application/components/shared/PillMultiSelect";
+
+const SKILLS_PREFIX = "/skills/";
+const MEMORIES_PREFIX = "/memories/";
+
+/**
+ * Multi-select of available skills for use inside the agent form. Each option
+ * value is the skill directory path (e.g. `/skills/rag/`); labels come from
+ * each skill's frontmatter `name`. Skills are fetched in parallel via
+ * `useQueries` so we don't create an N+1 fetch waterfall.
+ */
+export function SkillPillMultiSelect({
+ selected,
+ onChange,
+ readOnly = false,
+}: Readonly<{ selected: string[]; onChange?: (selected: string[]) => void; readOnly?: boolean }>) {
+ const { data: files } = useStoreFiles(SKILLS_PREFIX);
+
+ const skillPaths = useMemo(
+ () =>
+ (files ?? [])
+ .map((f) => f.path)
+ .filter((p) => p.endsWith("SKILL.md"))
+ .sort(),
+ [files],
+ );
+
+ // Fetch every SKILL.md in parallel to read its frontmatter name. The query
+ // key mirrors useStoreFile so results are shared/cached with the viewer.
+ const fileResults = useQueries({
+ queries: skillPaths.map((path) => ({
+ queryKey: ["store-file", path],
+ queryFn: () => storeApi.getFile(path),
+ })),
+ });
+
+ const options = useMemo
(() => {
+ return skillPaths.map((path, index) => {
+ const file = fileResults[index]?.data;
+ const dir = path.replace(/\/SKILL\.md$/, "");
+ const name = file ? parseFrontmatter(file.content).data.name : null;
+ return {
+ value: `${dir}/`,
+ label: name ?? deriveNameFromPath(path),
+ description: file ? parseFrontmatter(file.content).data.description : undefined,
+ };
+ });
+ }, [skillPaths, fileResults]);
+
+ return (
+
+ );
+}
+
+/**
+ * Multi-select of available memories for use inside the agent form. Each
+ * option value is the full memory path (e.g. `/memories/AGENTS.md`); the label
+ * is the filename.
+ */
+export function MemoryPillMultiSelect({
+ selected,
+ onChange,
+ readOnly = false,
+}: Readonly<{ selected: string[]; onChange?: (selected: string[]) => void; readOnly?: boolean }>) {
+ const { data: files } = useStoreFiles(MEMORIES_PREFIX);
+
+ const options = useMemo(
+ () =>
+ (files ?? [])
+ .map((f) => f.path)
+ .sort()
+ .map((path) => ({
+ value: path,
+ label: path.split("/").filter(Boolean).pop() ?? path,
+ })),
+ [files],
+ );
+
+ return (
+
+ );
+}
+
+function deriveNameFromPath(path: string): string {
+ // /skills/rag/SKILL.md -> rag
+ const parts = path.split("/").filter(Boolean);
+ return parts.length >= 2 ? parts[1] : path;
+}
diff --git a/src/application/components/layout/Sidebar.tsx b/src/application/components/layout/Sidebar.tsx
index c340807..1c01a0f 100644
--- a/src/application/components/layout/Sidebar.tsx
+++ b/src/application/components/layout/Sidebar.tsx
@@ -1,6 +1,6 @@
import { useEffect } from "react";
import { NavLink } from "react-router-dom";
-import { Bot, Database, MessagesSquare, Settings, X } from "lucide-react";
+import { BookOpen, Bot, Database, MessagesSquare, Settings, Sparkles, X } from "lucide-react";
import { cn } from "@/application/lib/utils";
import { useSidebarStore } from "@/application/stores/useSidebarStore";
@@ -14,6 +14,8 @@ interface NavItem {
const NAV_ITEMS: readonly NavItem[] = [
{ to: "/chat", label: "Orchestration", icon: MessagesSquare, odId: "nav-orchestration" },
{ to: "/agents", label: "Agents", icon: Bot, odId: "nav-agents" },
+ { to: "/skills", label: "Skills", icon: Sparkles, odId: "nav-skills" },
+ { to: "/memories", label: "Memories", icon: BookOpen, odId: "nav-memories" },
{ to: "/rag", label: "RAG Storage", icon: Database, odId: "nav-rag" },
] as const;
diff --git a/src/application/components/memory/CreateMemoryDialog.tsx b/src/application/components/memory/CreateMemoryDialog.tsx
new file mode 100644
index 0000000..d42572e
--- /dev/null
+++ b/src/application/components/memory/CreateMemoryDialog.tsx
@@ -0,0 +1,133 @@
+import { useState, type FormEvent } from "react";
+import { toast } from "sonner";
+import { useCreateMemory } from "@/application/hooks/memory/useCreateMemory";
+import { extractApiMessage } from "@/infrastructure/api/store/storeApi";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/application/components/ui/dialog";
+import { Button } from "@/application/components/ui/button";
+import { Input } from "@/application/components/ui/input";
+import { Label } from "@/application/components/ui/label";
+import { Textarea } from "@/application/components/ui/textarea";
+
+interface CreateMemoryDialogProps {
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+}
+
+const MEMORY_FORM_ID = "memory-create-form";
+
+/**
+ * Dialog to create a new memory file: a name (becomes
+ * `/memories/{name}.md`) and a markdown body. No frontmatter for memories.
+ */
+export function CreateMemoryDialog({ open, onOpenChange }: Readonly) {
+ const [name, setName] = useState("");
+ const [content, setContent] = useState("");
+ const createMemory = useCreateMemory();
+
+ function handleClose() {
+ setName("");
+ setContent("");
+ onOpenChange(false);
+ }
+
+ function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ toast.error("Memory name is required");
+ return;
+ }
+ createMemory.mutate(
+ { name: trimmedName, content },
+ {
+ onSuccess: () => {
+ toast.success("Memory created successfully");
+ handleClose();
+ },
+ onError: (error) => {
+ toast.error(extractApiMessage(error));
+ },
+ },
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default CreateMemoryDialog;
diff --git a/src/application/components/memory/MemoryCard.tsx b/src/application/components/memory/MemoryCard.tsx
new file mode 100644
index 0000000..acdab17
--- /dev/null
+++ b/src/application/components/memory/MemoryCard.tsx
@@ -0,0 +1,50 @@
+import { BookOpen, Settings } from "lucide-react";
+import { Button } from "@/application/components/ui/button";
+
+interface MemoryCardProps {
+ readonly name: string;
+ readonly preview: string;
+ readonly onConfigure: (name: string) => void;
+}
+
+/**
+ * Presentational card for a single memory file. Mirrors the SkillCard visual
+ * treatment. `name` is the filename, `preview` is a truncated content preview.
+ */
+export function MemoryCard({ name, preview, onConfigure }: Readonly) {
+ return (
+
+
+
+
+ {name}
+
+
{preview}
+
+
+
+
+
+ );
+}
+
+export default MemoryCard;
\ No newline at end of file
diff --git a/src/application/components/memory/MemoryGrid.tsx b/src/application/components/memory/MemoryGrid.tsx
new file mode 100644
index 0000000..34b86ee
--- /dev/null
+++ b/src/application/components/memory/MemoryGrid.tsx
@@ -0,0 +1,95 @@
+import { useMemo } from "react";
+import { Plus } from "lucide-react";
+import { useStoreFilePreviews } from "@/application/hooks/store/useStoreFilePreviews";
+import MemoryCard from "@/application/components/memory/MemoryCard";
+
+interface MemoryGridProps {
+ readonly onCreateNew: () => void;
+ readonly onConfigure: (name: string) => void;
+}
+
+const MEMORIES_PREFIX = "/memories/";
+const PREVIEW_CHARS = 300;
+
+export default function MemoryGrid({ onCreateNew, onConfigure }: Readonly) {
+ const { data: previews, isLoading, error } = useStoreFilePreviews(MEMORIES_PREFIX, PREVIEW_CHARS);
+
+ const sortedPreviews = useMemo(
+ () => (previews ?? []).slice().sort((a, b) => a.path.localeCompare(b.path)),
+ [previews],
+ );
+
+ if (isLoading) {
+ return (
+
+
+
Loading memories...
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Failed to load memories: {error.message}
+
+ );
+ }
+
+ if (sortedPreviews.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {sortedPreviews.map((item) => {
+ const name = deriveNameFromPath(item.path);
+ const preview =
+ item.preview.length > PREVIEW_CHARS
+ ? `${item.preview.slice(0, PREVIEW_CHARS)}…`
+ : item.preview;
+ return (
+
+
+
+ );
+ })}
+
+
+ );
+}
+
+function deriveNameFromPath(path: string): string {
+ const parts = path.split("/").filter(Boolean);
+ return parts.length > 0 ? parts[parts.length - 1] : path;
+}
+
+function MemoryCreateButton({ onClick }: Readonly<{ onClick: () => void }>) {
+ return (
+
+ );
+}
diff --git a/src/application/components/memory/MemoryViewer.tsx b/src/application/components/memory/MemoryViewer.tsx
new file mode 100644
index 0000000..caea042
--- /dev/null
+++ b/src/application/components/memory/MemoryViewer.tsx
@@ -0,0 +1,230 @@
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { Download, Pencil, Trash2 } from "lucide-react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { useStoreFile } from "@/application/hooks/store/useStoreFile";
+import { usePutStoreFile } from "@/application/hooks/store/usePutStoreFile";
+import { useDeleteStoreFile } from "@/application/hooks/store/useDeleteStoreFile";
+import { extractApiMessage } from "@/infrastructure/api/store/storeApi";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/application/components/ui/dialog";
+import { Button } from "@/application/components/ui/button";
+import { Input } from "@/application/components/ui/input";
+import { Label } from "@/application/components/ui/label";
+import { Textarea } from "@/application/components/ui/textarea";
+import { ScrollArea } from "@/application/components/ui/scroll-area";
+
+interface MemoryViewerProps {
+ readonly memoryPath: string;
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+}
+
+function resolveMemoryStorePath(memoryPath: string): string {
+ const trimmed = memoryPath.trim();
+ if (trimmed.startsWith("/memories/")) return trimmed;
+ const name = trimmed.replace(/^\/?memories\/?/, "").replace(/\.md$/, "");
+ return `/memories/${name}.md`;
+}
+
+interface MemoryEditFormProps {
+ readonly memoryName: string;
+ readonly initialContent: string;
+ readonly onSave: (content: string) => void;
+ readonly onCancel: () => void;
+ readonly isPending: boolean;
+}
+
+function MemoryEditForm({
+ memoryName,
+ initialContent,
+ onSave,
+ onCancel,
+ isPending,
+}: Readonly) {
+ const [content, setContent] = useState(initialContent);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function MemoryViewer({
+ memoryPath,
+ open,
+ onOpenChange,
+}: Readonly) {
+ const storePath = useMemo(() => resolveMemoryStorePath(memoryPath), [memoryPath]);
+ const { data: file, isLoading, error } = useStoreFile(open ? storePath : null);
+ const putFile = usePutStoreFile();
+ const deleteFile = useDeleteStoreFile();
+ const [mode, setMode] = useState<"view" | "edit">("view");
+ const [confirmDelete, setConfirmDelete] = useState(false);
+
+ if (!open) return null;
+
+ function handleSave(content: string) {
+ putFile.mutate(
+ { path: storePath, content },
+ {
+ onSuccess: () => {
+ toast.success("Memory saved successfully");
+ setMode("view");
+ },
+ onError: (err) => toast.error(extractApiMessage(err)),
+ },
+ );
+ }
+
+ function handleDelete() {
+ if (!confirmDelete) {
+ setConfirmDelete(true);
+ return;
+ }
+ deleteFile.mutate(storePath, {
+ onSuccess: () => {
+ toast.success(`Memory "${memoryPath}" deleted`);
+ onOpenChange(false);
+ setConfirmDelete(false);
+ },
+ onError: (err) => {
+ toast.error(extractApiMessage(err));
+ setConfirmDelete(false);
+ },
+ });
+ }
+
+ function handleExport() {
+ if (!file) return;
+ const blob = new Blob([file.content], { type: "text/markdown" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = storePath.split("/").filter(Boolean).pop() ?? "memory.md";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 100);
+ }
+
+ function handleClose() {
+ setMode("view");
+ setConfirmDelete(false);
+ onOpenChange(false);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/application/components/shared/PillMultiSelect.tsx b/src/application/components/shared/PillMultiSelect.tsx
new file mode 100644
index 0000000..c674363
--- /dev/null
+++ b/src/application/components/shared/PillMultiSelect.tsx
@@ -0,0 +1,80 @@
+import { cn } from "@/application/lib/utils";
+
+export interface PillMultiSelectOption {
+ value: string;
+ label: string;
+ description?: string;
+}
+
+interface PillMultiSelectProps {
+ options: PillMultiSelectOption[];
+ selected: string[];
+ onChange?: (selected: string[]) => void;
+ emptyMessage?: string;
+ readOnly?: boolean;
+}
+
+/**
+ * Multi-select rendered as toggle pills. Mirrors the styling of the former
+ * middleware toggles (mono, uppercase, tracked). Selection state is exposed
+ * via `aria-pressed` on each pill button.
+ *
+ * When `readOnly` is true, only selected options are rendered (no toggling).
+ */
+export function PillMultiSelect({
+ options,
+ selected,
+ onChange,
+ emptyMessage = "No items found",
+ readOnly = false,
+}: Readonly) {
+ const selectedSet = new Set(selected);
+ const visibleOptions = readOnly ? options.filter((o) => selectedSet.has(o.value)) : options;
+
+ if (visibleOptions.length === 0) {
+ return (
+
+ {emptyMessage}
+
+ );
+ }
+
+ function handleClick(value: string) {
+ if (!onChange) return;
+ if (selectedSet.has(value)) {
+ onChange(selected.filter((v) => v !== value));
+ } else {
+ onChange([...selected, value]);
+ }
+ }
+
+ return (
+
+ {visibleOptions.map((option) => {
+ const isActive = selectedSet.has(option.value);
+ return (
+
+ );
+ })}
+
+ );
+}
+
+export default PillMultiSelect;
diff --git a/src/application/components/skill/CreateSkillDialog.tsx b/src/application/components/skill/CreateSkillDialog.tsx
new file mode 100644
index 0000000..6f4154b
--- /dev/null
+++ b/src/application/components/skill/CreateSkillDialog.tsx
@@ -0,0 +1,157 @@
+import { useState, type FormEvent } from "react";
+import { toast } from "sonner";
+import { useCreateSkill } from "@/application/hooks/skill/useCreateSkill";
+import { extractApiMessage } from "@/infrastructure/api/store/storeApi";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/application/components/ui/dialog";
+import { Button } from "@/application/components/ui/button";
+import { Input } from "@/application/components/ui/input";
+import { Label } from "@/application/components/ui/label";
+import { Textarea } from "@/application/components/ui/textarea";
+
+interface CreateSkillDialogProps {
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+}
+
+const SKILL_FORM_ID = "skill-create-form";
+
+/**
+ * Dialog to create a new skill: name + description (frontmatter) and a
+ * markdown body. Submits via {@link useCreateSkill}, which writes
+ * `skills/{name}/SKILL.md`.
+ */
+export function CreateSkillDialog({ open, onOpenChange }: Readonly) {
+ const [name, setName] = useState("");
+ const [description, setDescription] = useState("");
+ const [content, setContent] = useState("");
+ const createSkill = useCreateSkill();
+
+ function handleClose() {
+ setName("");
+ setDescription("");
+ setContent("");
+ onOpenChange(false);
+ }
+
+ function handleSubmit(e: FormEvent) {
+ e.preventDefault();
+ const trimmedName = name.trim();
+ if (!trimmedName) {
+ toast.error("Skill name is required");
+ return;
+ }
+ if (!description.trim()) {
+ toast.error("Skill description is required");
+ return;
+ }
+
+ createSkill.mutate(
+ { name: trimmedName, description: description.trim(), content },
+ {
+ onSuccess: () => {
+ toast.success("Skill created successfully");
+ handleClose();
+ },
+ onError: (error) => {
+ toast.error(extractApiMessage(error));
+ },
+ },
+ );
+ }
+
+ return (
+
+ );
+}
+
+export default CreateSkillDialog;
diff --git a/src/application/components/skill/SkillCard.tsx b/src/application/components/skill/SkillCard.tsx
new file mode 100644
index 0000000..1248bf8
--- /dev/null
+++ b/src/application/components/skill/SkillCard.tsx
@@ -0,0 +1,50 @@
+import { Settings, Sparkles } from "lucide-react";
+import { Button } from "@/application/components/ui/button";
+
+interface SkillCardProps {
+ readonly name: string;
+ readonly description: string;
+ readonly onConfigure: (skillPath: string) => void;
+}
+
+/**
+ * Presentational card for a single skill. Mirrors the AgentCard visual
+ * treatment (border + block-shadow-raised + hover lift).
+ */
+export function SkillCard({ name, description, onConfigure }: Readonly) {
+ return (
+
+
+
+
+ {name}
+
+
{description}
+
+
+
+
+
+ );
+}
+
+export default SkillCard;
\ No newline at end of file
diff --git a/src/application/components/skill/SkillGrid.tsx b/src/application/components/skill/SkillGrid.tsx
new file mode 100644
index 0000000..047df09
--- /dev/null
+++ b/src/application/components/skill/SkillGrid.tsx
@@ -0,0 +1,93 @@
+import { useMemo } from "react";
+import { Plus } from "lucide-react";
+import { useStoreFilePreviews } from "@/application/hooks/store/useStoreFilePreviews";
+import { parseFrontmatter } from "@/application/lib/frontmatter";
+import SkillCard from "@/application/components/skill/SkillCard";
+
+interface SkillGridProps {
+ readonly onCreateNew: () => void;
+ readonly onConfigure: (skillName: string) => void;
+}
+
+const SKILLS_PREFIX = "/skills/";
+const PREVIEW_CHARS = 1000;
+
+export default function SkillGrid({ onCreateNew, onConfigure }: Readonly) {
+ const { data: previews, isLoading, error } = useStoreFilePreviews(SKILLS_PREFIX, PREVIEW_CHARS);
+
+ const skillCards = useMemo(() => {
+ return (previews ?? [])
+ .filter((p) => p.path.endsWith("SKILL.md"))
+ .sort((a, b) => a.path.localeCompare(b.path))
+ .map((item) => {
+ const { data } = parseFrontmatter(item.preview);
+ const name = data.name ?? deriveNameFromPath(item.path);
+ const description = data.description ?? "";
+ return { path: item.path, name, description };
+ });
+ }, [previews]);
+
+ if (isLoading) {
+ return (
+
+ );
+ }
+
+ if (error) {
+ return (
+
+
Failed to load skills: {error.message}
+
+ );
+ }
+
+ if (skillCards.length === 0) {
+ return (
+
+
+
+ );
+ }
+
+ return (
+
+ {skillCards.map((card) => (
+
+
+
+ ))}
+
+
+ );
+}
+
+function deriveNameFromPath(path: string): string {
+ const parts = path.split("/").filter(Boolean);
+ return parts.length >= 2 ? parts[1] : path;
+}
+
+function SkillCreateButton({ onClick }: Readonly<{ onClick: () => void }>) {
+ return (
+
+ );
+}
diff --git a/src/application/components/skill/SkillViewer.tsx b/src/application/components/skill/SkillViewer.tsx
new file mode 100644
index 0000000..a85df8c
--- /dev/null
+++ b/src/application/components/skill/SkillViewer.tsx
@@ -0,0 +1,276 @@
+import { useMemo, useState } from "react";
+import { toast } from "sonner";
+import { Download, Pencil, Trash2 } from "lucide-react";
+import ReactMarkdown from "react-markdown";
+import remarkGfm from "remark-gfm";
+import { useStoreFile } from "@/application/hooks/store/useStoreFile";
+import { usePutStoreFile } from "@/application/hooks/store/usePutStoreFile";
+import { useDeleteStoreFile } from "@/application/hooks/store/useDeleteStoreFile";
+import { buildFrontmatter, parseFrontmatter } from "@/application/lib/frontmatter";
+import { extractApiMessage } from "@/infrastructure/api/store/storeApi";
+import {
+ Dialog,
+ DialogContent,
+ DialogDescription,
+ DialogHeader,
+ DialogTitle,
+} from "@/application/components/ui/dialog";
+import { Button } from "@/application/components/ui/button";
+import { Input } from "@/application/components/ui/input";
+import { Label } from "@/application/components/ui/label";
+import { Textarea } from "@/application/components/ui/textarea";
+import { ScrollArea } from "@/application/components/ui/scroll-area";
+
+interface SkillViewerProps {
+ readonly skillPath: string;
+ readonly open: boolean;
+ readonly onOpenChange: (open: boolean) => void;
+}
+
+function resolveSkillStorePath(skillPath: string): string {
+ const trimmed = skillPath.trim();
+ if (trimmed.endsWith("SKILL.md")) return trimmed;
+ const name = trimmed.replace(/^\/?skills\/?/, "").replace(/\/$/, "");
+ return `/skills/${name}/SKILL.md`;
+}
+
+interface SkillEditFormProps {
+ readonly initialName: string;
+ readonly initialDescription: string;
+ readonly initialContent: string;
+ readonly onSave: (name: string, description: string, content: string) => void;
+ readonly onCancel: () => void;
+ readonly isPending: boolean;
+}
+
+function SkillEditForm({
+ initialName,
+ initialDescription,
+ initialContent,
+ onSave,
+ onCancel,
+ isPending,
+}: Readonly) {
+ const [name, setName] = useState(initialName);
+ const [description, setDescription] = useState(initialDescription);
+ const [content, setContent] = useState(initialContent);
+
+ return (
+
+
+
+ setName(e.target.value)}
+ placeholder="rag"
+ />
+
+
+
+ setDescription(e.target.value)}
+ placeholder="RAG queries"
+ />
+
+
+
+
+
+
+
+
+
+
+ );
+}
+
+export default function SkillViewer({ skillPath, open, onOpenChange }: Readonly) {
+ const storePath = useMemo(() => resolveSkillStorePath(skillPath), [skillPath]);
+ const { data: file, isLoading, error } = useStoreFile(open ? storePath : null);
+ const putFile = usePutStoreFile();
+ const deleteFile = useDeleteStoreFile();
+ const [mode, setMode] = useState<"view" | "edit">("view");
+ const [confirmDelete, setConfirmDelete] = useState(false);
+
+ if (!open) return null;
+
+ const parsed = file ? parseFrontmatter(file.content) : null;
+
+ function handleSave(name: string, description: string, content: string) {
+ if (!name) {
+ toast.error("Skill name is required");
+ return;
+ }
+ const markdown = buildFrontmatter({ name, description }, content);
+ putFile.mutate(
+ { path: storePath, content: markdown },
+ {
+ onSuccess: () => {
+ toast.success("Skill saved successfully");
+ setMode("view");
+ },
+ onError: (err) => toast.error(extractApiMessage(err)),
+ },
+ );
+ }
+
+ function handleDelete() {
+ if (!confirmDelete) {
+ setConfirmDelete(true);
+ return;
+ }
+ deleteFile.mutate(storePath, {
+ onSuccess: () => {
+ toast.success(`Skill "${skillPath}" deleted`);
+ onOpenChange(false);
+ setConfirmDelete(false);
+ },
+ onError: (err) => {
+ toast.error(extractApiMessage(err));
+ setConfirmDelete(false);
+ },
+ });
+ }
+
+ function handleExport() {
+ if (!file) return;
+ const blob = new Blob([file.content], { type: "text/markdown" });
+ const url = URL.createObjectURL(blob);
+ const a = document.createElement("a");
+ a.href = url;
+ a.download = storePath.split("/").filter(Boolean).pop() ?? "SKILL.md";
+ document.body.appendChild(a);
+ a.click();
+ a.remove();
+ setTimeout(() => URL.revokeObjectURL(url), 100);
+ }
+
+ function handleClose() {
+ setMode("view");
+ setConfirmDelete(false);
+ onOpenChange(false);
+ }
+
+ return (
+
+ );
+}
diff --git a/src/application/hooks/memory/useCreateMemory.ts b/src/application/hooks/memory/useCreateMemory.ts
new file mode 100644
index 0000000..b7e700c
--- /dev/null
+++ b/src/application/hooks/memory/useCreateMemory.ts
@@ -0,0 +1,40 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+
+export interface CreateMemoryInput {
+ name: string;
+ content: string;
+}
+
+const NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
+
+/**
+ * Create a new memory file at `/memories/{name}.md` containing the provided
+ * markdown content (no frontmatter for memories).
+ *
+ * Throws an error if a memory with the same name already exists, to prevent
+ * silent overwrite of the existing content.
+ */
+export function useCreateMemory() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ name, content }: CreateMemoryInput) => {
+ const trimmedName = name.trim().replace(/\.md$/, "");
+ if (!NAME_PATTERN.test(trimmedName)) {
+ throw new Error(
+ `Invalid memory name "${trimmedName}": only letters, digits, dots, hyphens, and underscores allowed`,
+ );
+ }
+ const path = `/memories/${trimmedName}.md`;
+ const existing = await storeApi.getFile(path);
+ if (existing !== null) {
+ throw new Error(`A memory named "${trimmedName}" already exists`);
+ }
+ return storeApi.putFile(path, content ?? "");
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["store-files"] });
+ },
+ });
+}
diff --git a/src/application/hooks/skill/useCreateSkill.ts b/src/application/hooks/skill/useCreateSkill.ts
new file mode 100644
index 0000000..8018a57
--- /dev/null
+++ b/src/application/hooks/skill/useCreateSkill.ts
@@ -0,0 +1,44 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+import { buildFrontmatter } from "@/application/lib/frontmatter";
+
+export interface CreateSkillInput {
+ name: string;
+ description: string;
+ content: string;
+}
+
+const NAME_PATTERN = /^[a-zA-Z0-9._-]+$/;
+
+/**
+ * Create a new skill by writing `skills/{name}/SKILL.md` with a YAML
+ * frontmatter block (name + description) followed by the markdown body.
+ *
+ * Throws an error if a skill with the same name already exists, to prevent
+ * silent overwrite of the existing content.
+ */
+export function useCreateSkill() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: async ({ name, description, content }: CreateSkillInput) => {
+ const trimmedName = name.trim();
+ if (!NAME_PATTERN.test(trimmedName)) {
+ throw new Error(
+ `Invalid skill name "${trimmedName}": only letters, digits, dots, hyphens, and underscores allowed`,
+ );
+ }
+ const path = `/skills/${trimmedName}/SKILL.md`;
+ const existing = await storeApi.getFile(path);
+ if (existing !== null) {
+ throw new Error(`A skill named "${trimmedName}" already exists`);
+ }
+ const body = content ?? "";
+ const markdown = buildFrontmatter({ name: trimmedName, description }, body);
+ return storeApi.putFile(path, markdown);
+ },
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["store-files"] });
+ },
+ });
+}
diff --git a/src/application/hooks/store/useDeleteStoreFile.ts b/src/application/hooks/store/useDeleteStoreFile.ts
new file mode 100644
index 0000000..90d12fe
--- /dev/null
+++ b/src/application/hooks/store/useDeleteStoreFile.ts
@@ -0,0 +1,17 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+
+/**
+ * Delete a store file. Invalidates the `["store-files"]` family so list
+ * views refetch.
+ */
+export function useDeleteStoreFile() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: (path: string) => storeApi.deleteFile(path),
+ onSuccess: () => {
+ queryClient.invalidateQueries({ queryKey: ["store-files"] });
+ },
+ });
+}
diff --git a/src/application/hooks/store/usePutStoreFile.ts b/src/application/hooks/store/usePutStoreFile.ts
new file mode 100644
index 0000000..23de2bd
--- /dev/null
+++ b/src/application/hooks/store/usePutStoreFile.ts
@@ -0,0 +1,19 @@
+import { useMutation, useQueryClient } from "@tanstack/react-query";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+
+/**
+ * Create or overwrite a store file. Invalidates the `["store-files"]` family
+ * so list views refetch.
+ */
+export function usePutStoreFile() {
+ const queryClient = useQueryClient();
+
+ return useMutation({
+ mutationFn: ({ path, content }: { path: string; content: string }) =>
+ storeApi.putFile(path, content),
+ onSuccess: (_data, variables) => {
+ queryClient.invalidateQueries({ queryKey: ["store-files"] });
+ queryClient.invalidateQueries({ queryKey: ["store-file", variables.path] });
+ },
+ });
+}
diff --git a/src/application/hooks/store/useStoreFile.ts b/src/application/hooks/store/useStoreFile.ts
new file mode 100644
index 0000000..417bb7e
--- /dev/null
+++ b/src/application/hooks/store/useStoreFile.ts
@@ -0,0 +1,17 @@
+import { useQuery } from "@tanstack/react-query";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+
+/**
+ * Fetch a single store file by path. Disabled when `path` is null/empty.
+ * Cached under `["store-file", path]`.
+ */
+export function useStoreFile(path: string | null) {
+ return useQuery({
+ queryKey: ["store-file", path],
+ queryFn: () => {
+ if (!path) throw new Error("path is required");
+ return storeApi.getFile(path);
+ },
+ enabled: !!path,
+ });
+}
diff --git a/src/application/hooks/store/useStoreFilePreviews.ts b/src/application/hooks/store/useStoreFilePreviews.ts
new file mode 100644
index 0000000..90b29a4
--- /dev/null
+++ b/src/application/hooks/store/useStoreFilePreviews.ts
@@ -0,0 +1,13 @@
+import { useQuery } from "@tanstack/react-query";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+
+/**
+ * Fetch files with a truncated content preview in a single request.
+ * Eliminates N+1 fetches when displaying memory cards or skill frontmatter.
+ */
+export function useStoreFilePreviews(prefix: string, chars: number = 300) {
+ return useQuery({
+ queryKey: ["store-file-previews", prefix, chars],
+ queryFn: () => storeApi.listFilePreviews(prefix, chars),
+ });
+}
diff --git a/src/application/hooks/store/useStoreFiles.ts b/src/application/hooks/store/useStoreFiles.ts
new file mode 100644
index 0000000..3563141
--- /dev/null
+++ b/src/application/hooks/store/useStoreFiles.ts
@@ -0,0 +1,13 @@
+import { useQuery } from "@tanstack/react-query";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+
+/**
+ * List store files under a path prefix. Cached under
+ * `["store-files", prefix]`; mutations (put/delete) invalidate the family.
+ */
+export function useStoreFiles(prefix: string) {
+ return useQuery({
+ queryKey: ["store-files", prefix],
+ queryFn: () => storeApi.listFiles(prefix),
+ });
+}
diff --git a/src/application/lib/frontmatter.ts b/src/application/lib/frontmatter.ts
new file mode 100644
index 0000000..010935b
--- /dev/null
+++ b/src/application/lib/frontmatter.ts
@@ -0,0 +1,123 @@
+import yaml from "js-yaml";
+
+export interface SkillFrontmatter {
+ name?: string;
+ description?: string;
+ [key: string]: unknown;
+}
+
+const FRONTMATTER_DELIMITER = "---";
+
+/**
+ * Parse a YAML frontmatter block delimited by leading `---` lines from a
+ * markdown document. Returns the parsed frontmatter data (possibly empty)
+ * and the remaining body content.
+ *
+ * Frontmatter without a body returns an empty body string. Documents without
+ * frontmatter return the original content as the body and an empty data
+ * object.
+ */
+export function parseFrontmatter(content: string): { data: SkillFrontmatter; body: string } {
+ if (!content.startsWith(FRONTMATTER_DELIMITER)) {
+ return { data: {}, body: content };
+ }
+
+ // Strip the leading delimiter line, then find the closing delimiter line.
+ const afterOpening = content.slice(FRONTMATTER_DELIMITER.length);
+ const newlineAfterOpen = afterOpening.search(/\r?\n/);
+ if (newlineAfterOpen === -1) {
+ return { data: {}, body: content };
+ }
+
+ const rest = afterOpening.slice(newlineAfterOpen + 1);
+ // Find the closing delimiter: a line that is exactly `---` (with optional
+ // trailing whitespace). The closing delimiter must be on its own line —
+ // search for `\n---` (or `---` at the start of rest) to avoid matching a
+ // `---` horizontal rule inside the body.
+ let closeMatchIndex = -1;
+ let closeMatchLength = 0;
+ // Check if rest starts with the closing delimiter immediately
+ if (/^---\s*(\r?\n|$)/.test(rest)) {
+ closeMatchIndex = 0;
+ closeMatchLength = rest.match(/^---\s*(\r?\n|$)/)![0].length;
+ } else {
+ // Search for `\n---` on its own line
+ const closeRegex = /\n---\s*(\r?\n|$)/g;
+ const match = closeRegex.exec(rest);
+ if (match) {
+ closeMatchIndex = match.index + 1; // +1 to skip the \n
+ closeMatchLength = match[0].length - 1; // exclude the leading \n
+ }
+ }
+ if (closeMatchIndex === -1) {
+ return { data: {}, body: content };
+ }
+
+ const yamlBlock = rest.slice(0, closeMatchIndex);
+ const bodyStart = closeMatchIndex + closeMatchLength;
+ const body = rest.slice(bodyStart);
+
+ let data: SkillFrontmatter = {};
+ try {
+ const parsed = yaml.load(yamlBlock);
+ if (parsed && typeof parsed === "object") {
+ data = parsed as SkillFrontmatter;
+ }
+ } catch {
+ // The frontmatter block is not valid YAML (e.g. an unquoted scalar value
+ // containing a colon). Fall back to a lenient line-by-line extraction of
+ // the known scalar keys so the document is still usable.
+ data = extractLenient(yamlBlock);
+ }
+
+ return { data, body };
+}
+
+/**
+ * Lenient fallback parser used when js-yaml cannot parse a frontmatter block.
+ * Extracts simple `key: value` scalar lines (one per line) without quoting
+ * rules. Handles the common case of a description containing characters that
+ * would make the block invalid YAML (e.g. an unquoted colon).
+ */
+function extractLenient(yamlBlock: string): SkillFrontmatter {
+ const data: SkillFrontmatter = {};
+ for (const line of yamlBlock.split(/\r?\n/)) {
+ const match = line.match(/^([A-Za-z_][\w-]*)\s*:\s*(.*)$/);
+ if (!match) continue;
+ const [, key, rawValue] = match;
+ let value = rawValue.trim();
+ // Strip a single layer of matching quotes if present.
+ if (
+ (value.startsWith('"') && value.endsWith('"')) ||
+ (value.startsWith("'") && value.endsWith("'"))
+ ) {
+ value = value.slice(1, -1);
+ }
+ data[key] = value;
+ }
+ return data;
+}
+
+/**
+ * Build a markdown document with a YAML frontmatter block containing the
+ * provided `name` and `description`, followed by the body content. Values
+ * that would otherwise be invalid YAML (e.g. containing a colon followed by a
+ * space, or starting with a quote) are double-quoted.
+ */
+export function buildFrontmatter(
+ data: { name: string; description: string },
+ body: string,
+): string {
+ const yamlBlock = `name: ${formatScalar(data.name)}\ndescription: ${formatScalar(data.description)}`;
+ const bodyPart = body ? body : "";
+ return `${FRONTMATTER_DELIMITER}\n${yamlBlock}\n${FRONTMATTER_DELIMITER}\n${bodyPart}`;
+}
+
+function formatScalar(value: string): string {
+ // Quote values that contain a YAML-meaningful construct so the output
+ // remains valid YAML and round-trips through parseFrontmatter.
+ if (/[:#\-?][\s]|^["']|[:]{1}$/.test(value) || value.includes("\n")) {
+ return JSON.stringify(value);
+ }
+ return value;
+}
diff --git a/src/application/lib/yaml.ts b/src/application/lib/yaml.ts
index e78e1aa..c1149cc 100644
--- a/src/application/lib/yaml.ts
+++ b/src/application/lib/yaml.ts
@@ -43,7 +43,6 @@ export function serializeAgentConfig(config: AgentConfig): string {
name: config.name,
model: config.model,
tools: config.tools,
- middleware: config.middleware,
backend: config.backend,
hitl: config.hitl,
memory: config.memory,
diff --git a/src/application/pages/AgentsPage.tsx b/src/application/pages/AgentsPage.tsx
index 0ebec1d..131c2a2 100644
--- a/src/application/pages/AgentsPage.tsx
+++ b/src/application/pages/AgentsPage.tsx
@@ -24,8 +24,8 @@ export default function AgentsPage() {
>
- Configure and manage your AI agent fleet. Each agent operates with its own tools,
- middleware, and orchestration rules.
+ Configure and manage your AI agent fleet. Each agent operates with its own tools and
+ orchestration rules.
diff --git a/src/application/pages/ChatPage.tsx b/src/application/pages/ChatPage.tsx
index e3a5b6a..6e4d406 100644
--- a/src/application/pages/ChatPage.tsx
+++ b/src/application/pages/ChatPage.tsx
@@ -11,11 +11,13 @@ import { useAgentConfig } from "@/application/hooks/agent/useAgentConfig";
export default function ChatPage() {
const { threadId } = useParams<{ threadId?: string }>();
const setActiveThread = useChatStore((s) => s.setActiveThread);
+ const clearStream = useChatStore((s) => s.clearStream);
const { data: threads } = useThreads();
useEffect(() => {
setActiveThread(threadId ?? null);
- }, [threadId, setActiveThread]);
+ clearStream();
+ }, [threadId, setActiveThread, clearStream]);
const agentName = useMemo(() => {
if (!threadId || !threads) return "Agent";
diff --git a/src/application/pages/MemoriesPage.tsx b/src/application/pages/MemoriesPage.tsx
new file mode 100644
index 0000000..c093d66
--- /dev/null
+++ b/src/application/pages/MemoriesPage.tsx
@@ -0,0 +1,63 @@
+import { useState } from "react";
+import { Plus } from "lucide-react";
+import AppShell from "@/application/components/layout/AppShell";
+import MemoryGrid from "@/application/components/memory/MemoryGrid";
+import CreateMemoryDialog from "@/application/components/memory/CreateMemoryDialog";
+import MemoryViewer from "@/application/components/memory/MemoryViewer";
+import { useStoreFiles } from "@/application/hooks/store/useStoreFiles";
+import { Badge } from "@/application/components/ui/badge";
+import { Button } from "@/application/components/ui/button";
+
+export default function MemoriesPage() {
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
+ const [viewerMemory, setViewerMemory] = useState(null);
+ const { data: files } = useStoreFiles("/memories/");
+ const total = files?.length ?? 0;
+
+ return (
+
+
+
+
+ Manage markdown memory files. Each memory lives at
+ /memories/<name>.md
+ and can be referenced by agents.
+
+
+
+ {total} memor{total === 1 ? "y" : "ies"}
+
+
+
+
+
+
setCreateDialogOpen(true)}
+ onConfigure={(name) => setViewerMemory(name)}
+ />
+
+
+
+ {viewerMemory && (
+ {
+ if (!open) setViewerMemory(null);
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/application/pages/SkillsPage.tsx b/src/application/pages/SkillsPage.tsx
new file mode 100644
index 0000000..2d5e383
--- /dev/null
+++ b/src/application/pages/SkillsPage.tsx
@@ -0,0 +1,63 @@
+import { useState } from "react";
+import { Plus } from "lucide-react";
+import AppShell from "@/application/components/layout/AppShell";
+import SkillGrid from "@/application/components/skill/SkillGrid";
+import CreateSkillDialog from "@/application/components/skill/CreateSkillDialog";
+import SkillViewer from "@/application/components/skill/SkillViewer";
+import { useStoreFiles } from "@/application/hooks/store/useStoreFiles";
+import { Badge } from "@/application/components/ui/badge";
+import { Button } from "@/application/components/ui/button";
+
+export default function SkillsPage() {
+ const [createDialogOpen, setCreateDialogOpen] = useState(false);
+ const [viewerSkill, setViewerSkill] = useState(null);
+ const { data: files } = useStoreFiles("/skills/");
+ const total = (files ?? []).filter((f) => f.path.endsWith("SKILL.md")).length;
+
+ return (
+
+
+
+
+ Create and manage reusable skills. Each skill lives at
+ /skills/<name>/SKILL.md
+ with YAML frontmatter and a markdown body.
+
+
+
+ {total} skill{total === 1 ? "" : "s"}
+
+
+
+
+
+
setCreateDialogOpen(true)}
+ onConfigure={(name) => setViewerSkill(name)}
+ />
+
+
+
+ {viewerSkill && (
+ {
+ if (!open) setViewerSkill(null);
+ }}
+ />
+ )}
+
+ );
+}
diff --git a/src/domain/entities/agent/agentConfig.ts b/src/domain/entities/agent/agentConfig.ts
index 200d9c2..e87d0bd 100644
--- a/src/domain/entities/agent/agentConfig.ts
+++ b/src/domain/entities/agent/agentConfig.ts
@@ -1,21 +1,12 @@
import type { McpServerConfig } from "./mcpServerConfig";
-export enum MiddlewareType {
- TODO_LIST = "todo_list",
- FILESYSTEM = "filesystem",
- SUB_AGENT = "sub_agent",
-}
-
export enum BackendType {
- STATE = "state",
STORE = "store",
- FILESYSTEM = "filesystem",
- COMPOSITE = "composite",
}
export interface BackendConfig {
type: BackendType;
- root_dir?: string;
+ checkpoint_backend: "memory" | "postgres";
}
export interface InterruptRule {
@@ -44,7 +35,6 @@ export interface AgentConfig {
system_prompt?: string;
system_prompt_file?: string;
tools: string[];
- middleware: MiddlewareType[];
backend: BackendConfig;
hitl: HITLConfig;
memory: string[];
diff --git a/src/domain/entities/agent/agentConfigSchema.ts b/src/domain/entities/agent/agentConfigSchema.ts
index f745417..5aa3981 100644
--- a/src/domain/entities/agent/agentConfigSchema.ts
+++ b/src/domain/entities/agent/agentConfigSchema.ts
@@ -1,5 +1,5 @@
import { z } from "zod";
-import { BackendType, MiddlewareType } from "./agentConfig";
+import { BackendType } from "./agentConfig";
import { McpTransportType } from "./mcpServerConfig";
export const interruptRuleSchema = z.object({
@@ -11,15 +11,12 @@ export const hitlConfigSchema = z.object({
rules: z.record(z.string(), z.union([z.boolean(), interruptRuleSchema])),
});
-export const backendConfigSchema = z.object({
- type: z.enum([
- BackendType.STATE,
- BackendType.STORE,
- BackendType.FILESYSTEM,
- BackendType.COMPOSITE,
- ]),
- root_dir: z.string().nullable().optional(),
-});
+export const backendConfigSchema = z
+ .object({
+ type: z.enum([BackendType.STORE]),
+ checkpoint_backend: z.enum(["memory", "postgres"]).default("memory"),
+ })
+ .strict();
export const mcpServerConfigSchema = z.object({
name: z.string().min(1, "MCP server name is required"),
@@ -43,30 +40,30 @@ export const subAgentConfigSchema = z.object({
response_format: z.record(z.string(), z.unknown()).nullable().optional(),
});
-export const agentConfigSchema = z.object({
- name: z
- .string()
- .min(1, "Agent name is required")
- .max(100, "Agent name must be 100 characters or less")
- .regex(
- /^[a-zA-Z0-9._-]+$/,
- "Agent name must contain only alphanumeric characters, dots, hyphens, and underscores",
- ),
- model: z.string().min(1, "Model is required"),
- system_prompt: z.string().nullable().optional(),
- system_prompt_file: z.string().nullable().optional(),
- tools: z.array(z.string()),
- middleware: z
- .enum([MiddlewareType.TODO_LIST, MiddlewareType.FILESYSTEM, MiddlewareType.SUB_AGENT])
- .array(),
- backend: backendConfigSchema,
- hitl: hitlConfigSchema,
- memory: z.array(z.string()),
- skills: z.array(z.string()),
- subagents: z.array(subAgentConfigSchema),
- mcp_servers: z.array(mcpServerConfigSchema),
- response_format: z.record(z.string(), z.unknown()).nullable().optional(),
- debug: z.boolean(),
-});
+export const agentConfigSchema = z
+ .object({
+ name: z
+ .string()
+ .min(1, "Agent name is required")
+ .max(100, "Agent name must be 100 characters or less")
+ .regex(
+ /^[a-zA-Z0-9._-]+$/,
+ "Agent name must contain only alphanumeric characters, dots, hyphens, and underscores",
+ ),
+ model: z.string().min(1, "Model is required"),
+ system_prompt: z.string().nullable().optional(),
+ system_prompt_file: z.string().nullable().optional(),
+ tools: z.array(z.string()),
+ backend: backendConfigSchema,
+ hitl: hitlConfigSchema,
+ memory: z.array(z.string()),
+ skills: z.array(z.string()),
+ subagents: z.array(subAgentConfigSchema),
+ mcp_servers: z.array(mcpServerConfigSchema),
+ response_format: z.record(z.string(), z.unknown()).nullable().optional(),
+ debug: z.boolean(),
+ })
+ .strict();
export type AgentConfigFormData = z.infer;
+export type AgentConfigFormInput = z.input;
diff --git a/src/domain/entities/store/storeFile.ts b/src/domain/entities/store/storeFile.ts
new file mode 100644
index 0000000..5b6ac49
--- /dev/null
+++ b/src/domain/entities/store/storeFile.ts
@@ -0,0 +1,8 @@
+export interface StoreFileMetadata {
+ path: string;
+}
+
+export interface StoreFile {
+ path: string;
+ content: string;
+}
diff --git a/src/domain/ports/store/storePort.ts b/src/domain/ports/store/storePort.ts
new file mode 100644
index 0000000..31ee6f7
--- /dev/null
+++ b/src/domain/ports/store/storePort.ts
@@ -0,0 +1,14 @@
+import type { StoreFile, StoreFileMetadata } from "@/domain/entities/store/storeFile";
+
+export interface StoreFilePreview {
+ path: string;
+ preview: string;
+}
+
+export interface IStorePort {
+ listFiles(prefix: string): Promise;
+ listFilePreviews(prefix: string, chars: number): Promise;
+ getFile(path: string): Promise;
+ putFile(path: string, content: string): Promise;
+ deleteFile(path: string): Promise;
+}
diff --git a/src/infrastructure/api/store/storeApi.ts b/src/infrastructure/api/store/storeApi.ts
new file mode 100644
index 0000000..ed6934f
--- /dev/null
+++ b/src/infrastructure/api/store/storeApi.ts
@@ -0,0 +1,74 @@
+import type { StoreFile, StoreFileMetadata } from "@/domain/entities/store/storeFile";
+import type { IStorePort } from "@/domain/ports/store/storePort";
+import { apiClient } from "@/infrastructure/api/axiosInstance";
+
+export interface StoreFilePreview {
+ path: string;
+ preview: string;
+}
+
+/**
+ * Axios-based implementation of {@link IStorePort}. The backend returns a list
+ * of path strings for `listFiles`; we map each to a `StoreFileMetadata`
+ * object. Missing files (HTTP 404) resolve to `null` for `getFile`.
+ */
+export const storeApi: IStorePort = {
+ async listFiles(prefix: string): Promise {
+ const response = await apiClient.get("/api/v1/store/files", { params: { prefix } });
+ return response.data.map((path) => ({ path }));
+ },
+
+ async listFilePreviews(prefix: string, chars: number): Promise {
+ const response = await apiClient.get("/api/v1/store/files/previews", {
+ params: { prefix, chars },
+ });
+ return response.data;
+ },
+
+ async getFile(path: string): Promise {
+ try {
+ const response = await apiClient.get(
+ `/api/v1/store/files/${encodeURIComponent(path)}`,
+ );
+ return response.data;
+ } catch (error) {
+ // 404 (or any "not found") resolves to null per the port contract.
+ if (isNotFound(error)) return null;
+ throw error;
+ }
+ },
+
+ async putFile(path: string, content: string): Promise {
+ await apiClient.put(`/api/v1/store/files/${encodeURIComponent(path)}`, {
+ content,
+ });
+ },
+
+ async deleteFile(path: string): Promise {
+ await apiClient.delete(`/api/v1/store/files/${encodeURIComponent(path)}`);
+ },
+};
+
+function isNotFound(error: unknown): boolean {
+ if (error && typeof error === "object" && "response" in error) {
+ const status = (error as { response?: { status?: number } }).response?.status;
+ return status === 404;
+ }
+ return false;
+}
+
+/**
+ * Extract a human-readable error message from an axios error. The backend's
+ * actual error body (`detail` or `message`) is preferred over axios's generic
+ * "Request failed with status code N" message.
+ */
+export function extractApiMessage(error: unknown): string {
+ if (error && typeof error === "object" && "response" in error) {
+ const data = (error as { response?: { data?: { detail?: unknown; message?: unknown } } })
+ .response?.data;
+ if (typeof data?.detail === "string" && data.detail) return data.detail;
+ if (typeof data?.message === "string" && data.message) return data.message;
+ }
+ if (error instanceof Error) return error.message;
+ return "Unknown error";
+}
diff --git a/tests/fixtures/external.ts b/tests/fixtures/external.ts
index 1f43499..b15e88b 100644
--- a/tests/fixtures/external.ts
+++ b/tests/fixtures/external.ts
@@ -40,8 +40,7 @@ export function createAgentConfig(overrides: Partial = {}): AgentCo
model: "openai:anthropic/claude-haiku-4.5:nitro",
system_prompt: "You are a helpful assistant.",
tools: ["search", "calculator"],
- middleware: [],
- backend: { type: BackendType.STATE },
+ backend: { type: BackendType.STORE, checkpoint_backend: "memory" },
hitl: { rules: {} },
memory: [],
skills: [],
diff --git a/tests/unit/application/lib/yaml.test.ts b/tests/unit/application/lib/yaml.test.ts
index aa14568..94c7b51 100644
--- a/tests/unit/application/lib/yaml.test.ts
+++ b/tests/unit/application/lib/yaml.test.ts
@@ -5,7 +5,7 @@ import {
agentConfigToYamlFile,
} from "@/application/lib/yaml";
import type { AgentConfig } from "@/domain/entities/agent/agentConfig";
-import { BackendType, MiddlewareType } from "@/domain/entities/agent/agentConfig";
+import { BackendType } from "@/domain/entities/agent/agentConfig";
import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
const fullConfig: AgentConfig = {
@@ -13,8 +13,10 @@ const fullConfig: AgentConfig = {
model: "openai:gpt-4o",
system_prompt: "You are a helpful assistant",
tools: ["search", "calculator"],
- middleware: [MiddlewareType.TODO_LIST],
- backend: { type: BackendType.STATE },
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "memory",
+ },
hitl: { rules: { create_file: true } },
memory: ["/mem/shared"],
skills: ["web-search"],
@@ -40,6 +42,22 @@ const fullConfig: AgentConfig = {
debug: false,
};
+const postgresBackendConfig: AgentConfig = {
+ name: "postgres-agent",
+ model: "openai:gpt-4o",
+ tools: [],
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "postgres",
+ },
+ hitl: { rules: {} },
+ memory: [],
+ skills: [],
+ subagents: [],
+ mcp_servers: [],
+ debug: false,
+};
+
describe("serializeAgentConfig", () => {
it("serializes a full config to YAML string", () => {
const yaml = serializeAgentConfig(fullConfig);
@@ -54,8 +72,10 @@ describe("serializeAgentConfig", () => {
name: "minimal",
model: "gpt-4",
tools: [],
- middleware: [],
- backend: { type: BackendType.STATE },
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "memory",
+ },
hitl: { rules: {} },
memory: [],
skills: [],
@@ -73,6 +93,33 @@ describe("serializeAgentConfig", () => {
expect(yaml).toContain("transport: stdio");
expect(yaml).toContain("name: researcher");
});
+
+ it("does NOT include a middleware key in serialized YAML", () => {
+ // Act
+ const yaml = serializeAgentConfig(fullConfig);
+
+ // Assert — the serialized YAML must not contain a middleware key
+ expect(yaml).not.toMatch(/^middleware:/m);
+ expect(yaml).not.toContain("middleware:");
+ });
+
+ it("does NOT include root_dir in serialized YAML", () => {
+ // Act
+ const yaml = serializeAgentConfig(fullConfig);
+
+ // Assert — root_dir must not appear in the serialized output
+ expect(yaml).not.toContain("root_dir");
+ });
+
+ it("includes checkpoint_backend when set to postgres", () => {
+ // Arrange — postgresBackendConfig sets checkpoint_backend to "postgres"
+
+ // Act
+ const yaml = serializeAgentConfig(postgresBackendConfig);
+
+ // Assert
+ expect(yaml).toContain("checkpoint_backend: postgres");
+ });
});
describe("parseAgentConfig", () => {
@@ -84,10 +131,9 @@ system_prompt: You are helpful
tools:
- search
- calculator
-middleware:
- - todo_list
backend:
- type: state
+ type: store
+ checkpoint_backend: memory
hitl:
rules:
create_file: true
@@ -109,10 +155,9 @@ debug: true
name: nested-agent
model: gpt-4
tools: []
-middleware: []
backend:
- type: filesystem
- root_dir: /data
+ type: store
+ checkpoint_backend: memory
hitl:
rules:
delete_file:
@@ -137,8 +182,9 @@ mcp_servers:
debug: false
`;
const config = parseAgentConfig(yaml);
- expect(config.backend.type).toBe(BackendType.FILESYSTEM);
- expect(config.backend.root_dir).toBe("/data");
+ expect(config.backend.type).toBe(BackendType.STORE);
+ expect(config.backend).toHaveProperty("checkpoint_backend", "memory");
+ expect(config.backend).not.toHaveProperty("root_dir");
expect(config.hitl.rules.delete_file).toEqual({
before: true,
after: false,
@@ -147,6 +193,87 @@ debug: false
expect(config.mcp_servers[0].url).toBe("http://localhost:8080/mcp");
});
+ it("rejects YAML containing a middleware key", () => {
+ // Arrange — middleware must no longer be a valid field
+ const yaml = `
+name: bad-agent
+model: gpt-4
+tools: []
+middleware:
+ - todo_list
+backend:
+ type: store
+ checkpoint_backend: memory
+hitl:
+ rules: {}
+memory: []
+skills: []
+subagents: []
+mcp_servers: []
+debug: false
+`;
+ // Act & Assert
+ expect(() => parseAgentConfig(yaml)).toThrow();
+ });
+
+ it("rejects YAML with filesystem backend type", () => {
+ const yaml = `
+name: fs-agent
+model: gpt-4
+tools: []
+backend:
+ type: filesystem
+ root_dir: /data
+hitl:
+ rules: {}
+memory: []
+skills: []
+subagents: []
+mcp_servers: []
+debug: false
+`;
+ expect(() => parseAgentConfig(yaml)).toThrow();
+ });
+
+ it("rejects YAML with composite backend type", () => {
+ const yaml = `
+name: comp-agent
+model: gpt-4
+tools: []
+backend:
+ type: composite
+ root_dir: /data
+hitl:
+ rules: {}
+memory: []
+skills: []
+subagents: []
+mcp_servers: []
+debug: false
+`;
+ expect(() => parseAgentConfig(yaml)).toThrow();
+ });
+
+ it("rejects YAML with root_dir in backend", () => {
+ const yaml = `
+name: root-agent
+model: gpt-4
+tools: []
+backend:
+ type: store
+ checkpoint_backend: memory
+ root_dir: /data
+hitl:
+ rules: {}
+memory: []
+skills: []
+subagents: []
+mcp_servers: []
+debug: false
+`;
+ expect(() => parseAgentConfig(yaml)).toThrow();
+ });
+
it("throws on invalid YAML", () => {
expect(() => parseAgentConfig("not: valid: yaml: :::")).toThrow();
});
@@ -182,4 +309,4 @@ describe("agentConfigToYamlFile", () => {
expect(parsed.name).toBe(fullConfig.name);
expect(parsed.model).toBe(fullConfig.model);
});
-});
+});
\ No newline at end of file
diff --git a/tests/unit/components/agent/AgentConfigForm.test.tsx b/tests/unit/components/agent/AgentConfigForm.test.tsx
index 4bcf5ec..ed56506 100644
--- a/tests/unit/components/agent/AgentConfigForm.test.tsx
+++ b/tests/unit/components/agent/AgentConfigForm.test.tsx
@@ -17,10 +17,104 @@ describe("AgentConfigForm", () => {
);
expect(screen.getByText("General")).toBeInTheDocument();
- expect(screen.getByText("Tools & Middleware")).toBeInTheDocument();
+ // After the change the section is renamed to just "Tools".
+ expect(screen.getByText("Tools")).toBeInTheDocument();
expect(screen.getByText("Backend")).toBeInTheDocument();
});
+ it("does NOT render a 'Tools & Middleware' section label", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // After the change, the label must be "Tools" only, not "Tools & Middleware".
+ expect(screen.queryByText("Tools & Middleware")).not.toBeInTheDocument();
+ });
+
+ it("does NOT render a middleware section", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // After the change, the Middleware label and toggle buttons must be gone.
+ expect(screen.queryByText("Middleware")).not.toBeInTheDocument();
+ // The middleware toggle buttons (todo_list, filesystem, sub_agent) must
+ // not appear as toggle buttons.
+ expect(
+ screen.queryByRole("button", { name: /^todo_list$/i }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /^filesystem$/i }),
+ ).not.toBeInTheDocument();
+ expect(
+ screen.queryByRole("button", { name: /^sub_agent$/i }),
+ ).not.toBeInTheDocument();
+ });
+
+ it("does NOT render filesystem in the backend type dropdown", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ // Expand the Backend accordion so the Type Select trigger is rendered.
+ await user.click(screen.getByRole("button", { name: "Backend" }));
+
+ // Act — open the backend type select dropdown.
+ await user.click(screen.getByRole("combobox"));
+
+ // Assert — filesystem must not be a selectable option.
+ await waitFor(() => {
+ expect(
+ screen.queryByRole("option", { name: "filesystem" }),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("does NOT render composite in the backend type dropdown", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: "Backend" }));
+ await user.click(screen.getByRole("combobox"));
+
+ await waitFor(() => {
+ expect(
+ screen.queryByRole("option", { name: "composite" }),
+ ).not.toBeInTheDocument();
+ });
+ });
+
+ it("does NOT render a root_dir input field", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // After the change, root_dir input must not be rendered for any backend.
+ expect(screen.queryByLabelText(/root directory/i)).not.toBeInTheDocument();
+ expect(screen.queryByText("Root Directory")).not.toBeInTheDocument();
+ });
+
+ it("renders checkpoint_backend select when backend type is store", async () => {
+ const user = userEvent.setup();
+ renderWithProviders(
+ ,
+ );
+
+ // Expand the Backend accordion.
+ await user.click(screen.getByRole("button", { name: "Backend" }));
+
+ // Assert — backend type is shown as static text "store".
+ expect(screen.getByText("store")).toBeInTheDocument();
+
+ // Assert — checkpoint_backend select must appear.
+ await waitFor(() => {
+ expect(screen.getByText(/checkpoint backend/i)).toBeInTheDocument();
+ });
+ });
+
it("renders name field enabled in create mode", () => {
renderWithProviders(
,
@@ -73,8 +167,10 @@ describe("AgentConfigForm", () => {
model: "openai:gpt-4o",
system_prompt: "You are helpful",
tools: ["search"],
- middleware: [],
- backend: { type: BackendType.STATE },
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "memory",
+ },
hitl: { rules: {} },
memory: [],
skills: [],
@@ -128,4 +224,4 @@ describe("AgentConfigForm", () => {
expect(screen.queryByRole("button", { name: /create/i })).not.toBeInTheDocument();
});
});
-});
+});
\ No newline at end of file
diff --git a/tests/unit/components/agent/AgentConfigViewer.test.tsx b/tests/unit/components/agent/AgentConfigViewer.test.tsx
index f88641d..59e6953 100644
--- a/tests/unit/components/agent/AgentConfigViewer.test.tsx
+++ b/tests/unit/components/agent/AgentConfigViewer.test.tsx
@@ -4,7 +4,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import { renderWithProviders } from "../../../utils/render";
import AgentConfigViewer from "@/application/components/agent/AgentConfigViewer";
import type { AgentConfig } from "@/domain/entities/agent/agentConfig";
-import { BackendType, MiddlewareType } from "@/domain/entities/agent/agentConfig";
+import { BackendType } from "@/domain/entities/agent/agentConfig";
const { mockAgentConfigData, mockDeleteMutate, mockUpdateMutate } = vi.hoisted(() => {
return {
@@ -47,13 +47,36 @@ vi.mock("@/application/lib/yaml", () => ({
agentConfigToYamlFile: vi.fn(() => new File(["yaml"], "test.yaml")),
}));
+vi.mock("@/infrastructure/api/store/storeApi", () => ({
+ storeApi: {
+ listFiles: vi.fn().mockResolvedValue([]),
+ getFile: vi.fn().mockResolvedValue(null),
+ putFile: vi.fn().mockResolvedValue(undefined),
+ deleteFile: vi.fn().mockResolvedValue(undefined),
+ },
+}));
+
+vi.mock("@/application/hooks/store/useStoreFiles", () => ({
+ useStoreFiles: (prefix: string) => ({
+ data:
+ prefix === "/skills/"
+ ? [{ path: "/skills/mcp/SKILL.md" }, { path: "/skills/rag/SKILL.md" }]
+ : prefix === "/memories/"
+ ? [{ path: "/memories/AGENTS.md" }, { path: "/memories/coding.md" }]
+ : [],
+ }),
+}));
+
+// Fixture for the AgentConfig type after the source change.
const fullConfig: AgentConfig = {
name: "test-agent",
model: "openai:gpt-4o",
system_prompt: "You are a helpful assistant that provides accurate information.",
tools: ["search", "calculator"],
- middleware: [],
- backend: { type: "state" as BackendType },
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "memory",
+ },
hitl: { rules: {} },
memory: [],
skills: [],
@@ -62,6 +85,15 @@ const fullConfig: AgentConfig = {
debug: false,
};
+const postgresBackendConfig: AgentConfig = {
+ ...fullConfig,
+ name: "postgres-agent",
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "postgres",
+ },
+};
+
describe("AgentConfigViewer", () => {
beforeEach(() => {
mockAgentConfigData.data = undefined;
@@ -120,14 +152,14 @@ describe("AgentConfigViewer", () => {
expect(screen.getByText("Tools (2)")).toBeInTheDocument();
});
- it("renders backend type", () => {
+ it("renders backend checkpoint_backend", () => {
mockAgentConfigData.data = fullConfig;
renderWithProviders(
,
);
- expect(screen.getByText(/state/)).toBeInTheDocument();
+ expect(screen.getByText("memory")).toBeInTheDocument();
});
it("shows delete button and requires confirmation", async () => {
@@ -156,19 +188,45 @@ describe("AgentConfigViewer", () => {
expect(screen.getByText("Off")).toBeInTheDocument();
});
- it("renders middleware list when present", () => {
- mockAgentConfigData.data = {
- ...fullConfig,
- middleware: [MiddlewareType.TODO_LIST, MiddlewareType.FILESYSTEM],
- };
+ it("does NOT render a middleware section", () => {
+ // Arrange — after the change, the viewer must not render any middleware heading/label.
+ mockAgentConfigData.data = fullConfig;
renderWithProviders(
,
);
- expect(screen.getByText("Middleware (2)")).toBeInTheDocument();
- expect(screen.getByText("todo_list")).toBeInTheDocument();
- expect(screen.getByText("filesystem")).toBeInTheDocument();
+ // Assert — no "Middleware" section label or middleware tags.
+ expect(screen.queryByText(/^Middleware \(\d+\)$/)).not.toBeInTheDocument();
+ expect(screen.queryByText("Middleware")).not.toBeInTheDocument();
+ expect(screen.queryByText("todo_list")).not.toBeInTheDocument();
+ expect(screen.queryByText("filesystem")).not.toBeInTheDocument();
+ });
+
+ it("does NOT render root_dir", () => {
+ // Arrange — a config whose backend has no root_dir.
+ mockAgentConfigData.data = fullConfig;
+
+ renderWithProviders(
+ ,
+ );
+
+ // Assert — the root_dir value must not appear, and "Root:" label must be gone.
+ expect(screen.queryByText(/\/data\/agents/)).not.toBeInTheDocument();
+ expect(screen.queryByText(/Root:/)).not.toBeInTheDocument();
+ });
+
+ it("renders checkpoint_backend", () => {
+ // Arrange
+ mockAgentConfigData.data = postgresBackendConfig;
+
+ renderWithProviders(
+ ,
+ );
+
+ // Assert — checkpoint_backend label must be rendered.
+ expect(screen.getByText(/checkpoint_backend/i)).toBeInTheDocument();
+ expect(screen.getByText("postgres")).toBeInTheDocument();
});
it("renders HITL rules when present", () => {
@@ -234,19 +292,6 @@ describe("AgentConfigViewer", () => {
expect(screen.getByText("Research subagent")).toBeInTheDocument();
});
- it("renders backend root_dir when present", () => {
- mockAgentConfigData.data = {
- ...fullConfig,
- backend: { type: BackendType.FILESYSTEM, root_dir: "/data/agents" },
- };
-
- renderWithProviders(
- ,
- );
-
- expect(screen.getByText(/\/data\/agents/)).toBeInTheDocument();
- });
-
it("expands and collapses long system prompt", async () => {
const longPrompt = "A".repeat(300);
mockAgentConfigData.data = {
@@ -315,4 +360,62 @@ describe("AgentConfigViewer", () => {
expect(screen.getByRole("button", { name: /export yaml/i })).toBeInTheDocument();
});
-});
+
+ it("renders skills list when present", () => {
+ // Arrange — a config with two skills paths.
+ mockAgentConfigData.data = {
+ ...fullConfig,
+ skills: ["/skills/mcp/", "/skills/rag/"],
+ };
+
+ renderWithProviders(
+ ,
+ );
+
+ // Assert — the Skills section label with count and skill names as pills.
+ expect(screen.getByText("Skills (2)")).toBeInTheDocument();
+ expect(screen.getByText(/^mcp$/i)).toBeInTheDocument();
+ expect(screen.getByText(/^rag$/i)).toBeInTheDocument();
+ });
+
+ it("renders memories list when present", () => {
+ // Arrange — a config with two memory paths.
+ mockAgentConfigData.data = {
+ ...fullConfig,
+ memory: ["/memories/AGENTS.md", "/memories/coding.md"],
+ };
+
+ renderWithProviders(
+ ,
+ );
+
+ // Assert — the Memories section label with count and filenames as pills.
+ expect(screen.getByText("Memories (2)")).toBeInTheDocument();
+ expect(screen.getByText(/^agents\.md$/i)).toBeInTheDocument();
+ expect(screen.getByText(/^coding\.md$/i)).toBeInTheDocument();
+ });
+
+ it("does NOT render skills section when empty", () => {
+ // Arrange — a config with an empty skills array.
+ mockAgentConfigData.data = { ...fullConfig, skills: [] };
+
+ renderWithProviders(
+ ,
+ );
+
+ // Assert — no Skills section label is rendered.
+ expect(screen.queryByText(/Skills \(/)).not.toBeInTheDocument();
+ });
+
+ it("does NOT render memories section when empty", () => {
+ // Arrange — a config with an empty memory array.
+ mockAgentConfigData.data = { ...fullConfig, memory: [] };
+
+ renderWithProviders(
+ ,
+ );
+
+ // Assert — no Memories section label is rendered.
+ expect(screen.queryByText(/Memories \(/)).not.toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/components/memory/CreateMemoryDialog.test.tsx b/tests/unit/components/memory/CreateMemoryDialog.test.tsx
new file mode 100644
index 0000000..9f7f150
--- /dev/null
+++ b/tests/unit/components/memory/CreateMemoryDialog.test.tsx
@@ -0,0 +1,69 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+
+const { mockCreateMemoryMutate } = vi.hoisted(() => {
+ return {
+ mockCreateMemoryMutate: vi.fn(),
+ };
+});
+
+vi.mock("@/application/hooks/memory/useCreateMemory", () => ({
+ useCreateMemory: () => ({
+ mutate: mockCreateMemoryMutate,
+ isPending: false,
+ }),
+}));
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+import { CreateMemoryDialog } from "@/application/components/memory/CreateMemoryDialog";
+
+describe("CreateMemoryDialog", () => {
+ beforeEach(() => {
+ mockCreateMemoryMutate.mockClear();
+ });
+
+ it("renders name input", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
+ });
+
+ it("renders content textarea", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByLabelText(/content/i)).toBeInTheDocument();
+ });
+
+ it("renders Create button", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument();
+ });
+
+ it("calls onOpenChange(false) when Cancel clicked", async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/components/memory/MemoryCard.test.tsx b/tests/unit/components/memory/MemoryCard.test.tsx
new file mode 100644
index 0000000..8ce3024
--- /dev/null
+++ b/tests/unit/components/memory/MemoryCard.test.tsx
@@ -0,0 +1,49 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import { MemoryCard } from "@/application/components/memory/MemoryCard";
+
+describe("MemoryCard", () => {
+ it("renders memory name", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("AGENTS.md")).toBeInTheDocument();
+ });
+
+ it("renders content preview", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("# Project rules...")).toBeInTheDocument();
+ });
+
+ it("calls onConfigure when Configure button is clicked", async () => {
+ const user = userEvent.setup();
+ const onConfigure = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /configure/i }));
+
+ expect(onConfigure).toHaveBeenCalledOnce();
+ expect(onConfigure).toHaveBeenCalledWith("AGENTS.md");
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/components/shared/PillMultiSelect.test.tsx b/tests/unit/components/shared/PillMultiSelect.test.tsx
new file mode 100644
index 0000000..034a8c7
--- /dev/null
+++ b/tests/unit/components/shared/PillMultiSelect.test.tsx
@@ -0,0 +1,90 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import { PillMultiSelect } from "@/application/components/shared/PillMultiSelect";
+
+describe("PillMultiSelect", () => {
+ const options = [
+ { value: "a", label: "Alpha" },
+ { value: "b", label: "Beta" },
+ ];
+
+ it("renders all options as toggle pills", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("Alpha")).toBeInTheDocument();
+ expect(screen.getByText("Beta")).toBeInTheDocument();
+ });
+
+ it("highlights selected pills with active styling", () => {
+ renderWithProviders(
+ ,
+ );
+
+ // The selected pill ("Alpha") must carry an aria-pressed state distinguishing it
+ const alphaPill = screen.getByRole("button", { name: /alpha/i });
+ expect(alphaPill).toHaveAttribute("aria-pressed", "true");
+
+ // The unselected pill ("Beta") must not be pressed
+ const betaPill = screen.getByRole("button", { name: /beta/i });
+ expect(betaPill).toHaveAttribute("aria-pressed", "false");
+ });
+
+ it("calls onChange when a pill is clicked", async () => {
+ const user = userEvent.setup();
+ const onChange = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /alpha/i }));
+
+ expect(onChange).toHaveBeenCalledOnce();
+ expect(onChange).toHaveBeenCalledWith(["a"]);
+ });
+
+ it("shows emptyMessage when options array is empty", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("No items found")).toBeInTheDocument();
+ });
+
+ it("shows emptyMessage with custom text when provided", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("Aucune compétence disponible")).toBeInTheDocument();
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/components/skill/CreateSkillDialog.test.tsx b/tests/unit/components/skill/CreateSkillDialog.test.tsx
new file mode 100644
index 0000000..c24fe9f
--- /dev/null
+++ b/tests/unit/components/skill/CreateSkillDialog.test.tsx
@@ -0,0 +1,77 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+
+const { mockCreateSkillMutate } = vi.hoisted(() => {
+ return {
+ mockCreateSkillMutate: vi.fn(),
+ };
+});
+
+vi.mock("@/application/hooks/skill/useCreateSkill", () => ({
+ useCreateSkill: () => ({
+ mutate: mockCreateSkillMutate,
+ isPending: false,
+ }),
+}));
+
+vi.mock("sonner", () => ({
+ toast: {
+ success: vi.fn(),
+ error: vi.fn(),
+ },
+}));
+
+import { CreateSkillDialog } from "@/application/components/skill/CreateSkillDialog";
+
+describe("CreateSkillDialog", () => {
+ beforeEach(() => {
+ mockCreateSkillMutate.mockClear();
+ });
+
+ it("renders name input", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByLabelText(/name/i)).toBeInTheDocument();
+ });
+
+ it("renders description input", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByLabelText(/description/i)).toBeInTheDocument();
+ });
+
+ it("renders content textarea", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByLabelText(/content/i)).toBeInTheDocument();
+ });
+
+ it("renders Create button", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByRole("button", { name: /create/i })).toBeInTheDocument();
+ });
+
+ it("calls onOpenChange(false) when Cancel clicked", async () => {
+ const user = userEvent.setup();
+ const onOpenChange = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /cancel/i }));
+
+ expect(onOpenChange).toHaveBeenCalledWith(false);
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/components/skill/SkillCard.test.tsx b/tests/unit/components/skill/SkillCard.test.tsx
new file mode 100644
index 0000000..2d7e09b
--- /dev/null
+++ b/tests/unit/components/skill/SkillCard.test.tsx
@@ -0,0 +1,37 @@
+import { screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { describe, it, expect, vi } from "vitest";
+import { renderWithProviders } from "../../../utils/render";
+import { SkillCard } from "@/application/components/skill/SkillCard";
+
+describe("SkillCard", () => {
+ it("renders skill name", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("rag")).toBeInTheDocument();
+ });
+
+ it("renders skill description", () => {
+ renderWithProviders(
+ ,
+ );
+
+ expect(screen.getByText("RAG queries")).toBeInTheDocument();
+ });
+
+ it("calls onConfigure when Configure button is clicked", async () => {
+ const user = userEvent.setup();
+ const onConfigure = vi.fn();
+
+ renderWithProviders(
+ ,
+ );
+
+ await user.click(screen.getByRole("button", { name: /configure/i }));
+
+ expect(onConfigure).toHaveBeenCalledOnce();
+ expect(onConfigure).toHaveBeenCalledWith("rag");
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/domain/entities/agentConfig.test.ts b/tests/unit/domain/entities/agentConfig.test.ts
index 7926044..b10abeb 100644
--- a/tests/unit/domain/entities/agentConfig.test.ts
+++ b/tests/unit/domain/entities/agentConfig.test.ts
@@ -1,21 +1,117 @@
import { describe, it, expect } from "vitest";
-import { MiddlewareType, BackendType } from "@/domain/entities/agent/agentConfig";
+import { BackendType } from "@/domain/entities/agent/agentConfig";
+import { backendConfigSchema, agentConfigSchema } from "@/domain/entities/agent/agentConfigSchema";
import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
+describe("BackendType", () => {
+ it("has the correct value for STORE", () => {
+ expect(BackendType.STORE).toBe("store");
+ });
+
+ it("does NOT have a FILESYSTEM member", () => {
+ // After the change, BackendType.FILESYSTEM must not exist.
+ expect(
+ (BackendType as unknown as Record).FILESYSTEM,
+ ).toBeUndefined();
+ });
+
+ it("does NOT have a COMPOSITE member", () => {
+ // After the change, BackendType.COMPOSITE must not exist.
+ expect(
+ (BackendType as unknown as Record).COMPOSITE,
+ ).toBeUndefined();
+ });
+
+ it("only has STORE member", () => {
+ const allValues = Object.values(BackendType);
+ expect(allValues).toEqual(["store"]);
+ expect(allValues).toHaveLength(1);
+ });
+});
+
describe("MiddlewareType", () => {
- it("has the correct values", () => {
- expect(MiddlewareType.TODO_LIST).toBe("todo_list");
- expect(MiddlewareType.FILESYSTEM).toBe("filesystem");
- expect(MiddlewareType.SUB_AGENT).toBe("sub_agent");
+ it("is NOT exported from agentConfig", async () => {
+ // After the change, MiddlewareType must be removed from the module exports.
+ const mod = await import("@/domain/entities/agent/agentConfig");
+ expect((mod as Record).MiddlewareType).toBeUndefined();
});
});
-describe("BackendType", () => {
- it("has the correct values", () => {
- expect(BackendType.STATE).toBe("state");
- expect(BackendType.STORE).toBe("store");
- expect(BackendType.FILESYSTEM).toBe("filesystem");
- expect(BackendType.COMPOSITE).toBe("composite");
+describe("BackendConfig defaults (via schema)", () => {
+ it("checkpoint_backend defaults to 'memory' when omitted", () => {
+ // Arrange — omit checkpoint_backend
+ const parsed = backendConfigSchema.parse({ type: BackendType.STORE });
+ // Assert — the parsed config should include checkpoint_backend = "memory"
+ expect(parsed).toHaveProperty("checkpoint_backend", "memory");
+ });
+
+ it("rejects root_dir in parsed output", () => {
+ // Arrange — provide root_dir (should be rejected by the strict schema)
+ // Act & Assert — parsing with root_dir should throw
+ expect(() =>
+ backendConfigSchema.parse({
+ type: BackendType.STORE,
+ root_dir: "/data",
+ }),
+ ).toThrow();
+ });
+
+ it("accepts checkpoint_backend set to 'postgres'", () => {
+ const parsed = backendConfigSchema.parse({
+ type: BackendType.STORE,
+ checkpoint_backend: "postgres",
+ });
+ expect(parsed).toHaveProperty("checkpoint_backend", "postgres");
+ });
+});
+
+describe("AgentConfig has no middleware field (via schema)", () => {
+ it("accepts a config WITHOUT middleware", () => {
+ // Arrange — a valid config that omits middleware entirely
+ const config = {
+ name: "no-middleware-agent",
+ model: "openai:gpt-4o",
+ tools: [],
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "memory",
+ },
+ hitl: { rules: {} },
+ memory: [],
+ skills: [],
+ subagents: [],
+ mcp_servers: [],
+ debug: false,
+ };
+
+ // Act & Assert — parsing should succeed (middleware is no longer required)
+ const parsed = agentConfigSchema.parse(config);
+ expect(parsed).not.toHaveProperty("middleware");
+ });
+
+ it("does NOT include middleware in parsed output", () => {
+ // Arrange
+ const config = {
+ name: "clean-agent",
+ model: "openai:gpt-4o",
+ tools: [],
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "memory",
+ },
+ hitl: { rules: {} },
+ memory: [],
+ skills: [],
+ subagents: [],
+ mcp_servers: [],
+ debug: false,
+ };
+
+ // Act
+ const parsed = agentConfigSchema.parse(config);
+
+ // Assert — middleware must not be present in the parsed result
+ expect(parsed).not.toHaveProperty("middleware");
});
});
@@ -24,4 +120,4 @@ describe("McpTransportType", () => {
expect(McpTransportType.STDIO).toBe("stdio");
expect(McpTransportType.HTTP).toBe("http");
});
-});
+});
\ No newline at end of file
diff --git a/tests/unit/domain/entities/agentConfigSchema.test.ts b/tests/unit/domain/entities/agentConfigSchema.test.ts
index d9c37b3..9b99129 100644
--- a/tests/unit/domain/entities/agentConfigSchema.test.ts
+++ b/tests/unit/domain/entities/agentConfigSchema.test.ts
@@ -7,7 +7,7 @@ import {
mcpServerConfigSchema,
subAgentConfigSchema,
} from "@/domain/entities/agent/agentConfigSchema";
-import { BackendType, MiddlewareType } from "@/domain/entities/agent/agentConfig";
+import { BackendType } from "@/domain/entities/agent/agentConfig";
import { McpTransportType } from "@/domain/entities/agent/mcpServerConfig";
describe("interruptRuleSchema", () => {
@@ -49,31 +49,80 @@ describe("hitlConfigSchema", () => {
});
describe("backendConfigSchema", () => {
- it("accepts state type without root_dir", () => {
- expect(backendConfigSchema.parse({ type: BackendType.STATE })).toEqual({
- type: BackendType.STATE,
+ it("accepts store type without root_dir", () => {
+ const parsed = backendConfigSchema.parse({ type: BackendType.STORE });
+ expect(parsed.type).toBe(BackendType.STORE);
+ expect(parsed).not.toHaveProperty("root_dir");
+ });
+
+ it("rejects state backend type", () => {
+ expect(() => backendConfigSchema.parse({ type: "state" })).toThrow();
+ });
+
+ it("accepts store type with checkpoint_backend", () => {
+ const parsed = backendConfigSchema.parse({
+ type: BackendType.STORE,
+ checkpoint_backend: "postgres",
});
+ expect(parsed).toHaveProperty("checkpoint_backend", "postgres");
+ });
+
+ it("defaults checkpoint_backend to 'memory' when omitted", () => {
+ const parsed = backendConfigSchema.parse({ type: BackendType.STORE });
+ expect(parsed).toHaveProperty("checkpoint_backend", "memory");
});
- it("accepts filesystem type with root_dir", () => {
- expect(
+ it("rejects invalid backend type", () => {
+ expect(() => backendConfigSchema.parse({ type: "invalid" })).toThrow();
+ });
+
+ it("rejects filesystem backend type", () => {
+ // Arrange — filesystem must no longer be a valid backend type
+ // Act & Assert — parsing should throw
+ expect(() =>
+ backendConfigSchema.parse({ type: "filesystem" }),
+ ).toThrow();
+ });
+
+ it("rejects composite backend type", () => {
+ // Arrange — composite must no longer be a valid backend type
+ // Act & Assert — parsing should throw
+ expect(() =>
+ backendConfigSchema.parse({ type: "composite" }),
+ ).toThrow();
+ });
+
+ it("rejects root_dir field", () => {
+ // Arrange — root_dir must no longer be accepted by the schema
+ // Act & Assert — parsing with root_dir should throw (strict mode rejects unknown keys)
+ expect(() =>
backendConfigSchema.parse({
- type: BackendType.FILESYSTEM,
+ type: BackendType.STORE,
root_dir: "/data",
}),
- ).toEqual({ type: BackendType.FILESYSTEM, root_dir: "/data" });
+ ).toThrow();
});
- it("rejects invalid backend type", () => {
- expect(() => backendConfigSchema.parse({ type: "invalid" })).toThrow();
+ it("rejects invalid store_backend value", () => {
+ // Arrange — store_backend is no longer a valid field, so providing it should throw
+ // Act & Assert — an invalid (now unknown) key should throw
+ expect(() =>
+ backendConfigSchema.parse({
+ type: BackendType.STORE,
+ store_backend: "redis",
+ }),
+ ).toThrow();
});
- it("accepts null root_dir", () => {
- const result = backendConfigSchema.parse({
- type: BackendType.STATE,
- root_dir: null,
- });
- expect(result.root_dir).toBeNull();
+ it("rejects invalid checkpoint_backend value", () => {
+ // Arrange — only "memory" and "postgres" are valid
+ // Act & Assert — an invalid value should throw
+ expect(() =>
+ backendConfigSchema.parse({
+ type: BackendType.STORE,
+ checkpoint_backend: "redis",
+ }),
+ ).toThrow();
});
});
@@ -154,63 +203,125 @@ describe("subAgentConfigSchema", () => {
});
describe("agentConfigSchema", () => {
- it("accepts minimal valid config", () => {
- const result = agentConfigSchema.parse({
- name: "test-agent",
- model: "openai:gpt-4o",
- tools: [],
- middleware: [],
- backend: { type: BackendType.STATE },
- hitl: { rules: {} },
- memory: [],
- skills: [],
- subagents: [],
- mcp_servers: [],
- debug: false,
- });
+ const baseConfig = {
+ name: "test-agent",
+ model: "openai:gpt-4o",
+ tools: [],
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "memory",
+ },
+ hitl: { rules: {} },
+ memory: [],
+ skills: [],
+ subagents: [],
+ mcp_servers: [],
+ debug: false,
+ };
+
+ it("accepts minimal valid config without middleware", () => {
+ // Arrange — omit middleware entirely
+ // Act
+ const result = agentConfigSchema.parse(baseConfig);
+ // Assert
expect(result.name).toBe("test-agent");
+ expect(result).not.toHaveProperty("middleware");
});
- it("accepts full valid config", () => {
- const config = {
- name: "full-agent",
- model: "openai:claude-haiku-4.5",
- system_prompt: "You are helpful",
- tools: ["search", "calculator"],
- middleware: [MiddlewareType.TODO_LIST, MiddlewareType.FILESYSTEM],
- backend: { type: BackendType.FILESYSTEM, root_dir: "/data" },
- hitl: {
- rules: {
- create_file: true,
- delete_file: { before: true, after: false },
- },
+ it("rejects middleware field", () => {
+ // Arrange — middleware must no longer be a valid field
+ const configWithMiddleware = {
+ ...baseConfig,
+ middleware: [],
+ };
+ // Act & Assert — strict schema should reject unknown key "middleware"
+ expect(() => agentConfigSchema.parse(configWithMiddleware)).toThrow();
+ });
+
+ it("rejects middleware field with values", () => {
+ // Arrange — even with valid-looking values, middleware must be rejected
+ const configWithMiddleware = {
+ ...baseConfig,
+ middleware: ["todo_list"],
+ };
+ // Act & Assert
+ expect(() => agentConfigSchema.parse(configWithMiddleware)).toThrow();
+ });
+
+ it("rejects filesystem backend type", () => {
+ // Arrange
+ const configWithFilesystem = {
+ ...baseConfig,
+ backend: { type: "filesystem", root_dir: "/data" },
+ };
+ // Act & Assert
+ expect(() => agentConfigSchema.parse(configWithFilesystem)).toThrow();
+ });
+
+ it("rejects composite backend type", () => {
+ // Arrange
+ const configWithComposite = {
+ ...baseConfig,
+ backend: { type: "composite", root_dir: "/data" },
+ };
+ // Act & Assert
+ expect(() => agentConfigSchema.parse(configWithComposite)).toThrow();
+ });
+
+ it("rejects root_dir in backend", () => {
+ // Arrange
+ const configWithRootDir = {
+ ...baseConfig,
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "memory",
+ root_dir: "/data",
+ },
+ };
+ // Act & Assert — root_dir must no longer be accepted
+ expect(() => agentConfigSchema.parse(configWithRootDir)).toThrow();
+ });
+
+ it("accepts checkpoint_backend set to postgres", () => {
+ // Arrange
+ const configWithPostgres = {
+ ...baseConfig,
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "postgres",
+ },
+ };
+ // Act
+ const result = agentConfigSchema.parse(configWithPostgres);
+ // Assert
+ expect(result.backend).toHaveProperty("checkpoint_backend", "postgres");
+ });
+
+ it("rejects store_backend field (removed)", () => {
+ // Arrange — store_backend is no longer a valid field
+ const configWithStoreBackend = {
+ ...baseConfig,
+ backend: {
+ type: BackendType.STORE,
+ store_backend: "memory",
+ checkpoint_backend: "memory",
},
- memory: ["/mem/1"],
- skills: ["web-search"],
- subagents: [
- {
- name: "sub",
- description: "Sub agent",
- tools: [],
- skills: [],
- mcp_servers: [],
- },
- ],
- mcp_servers: [
- {
- name: "mcp",
- transport: McpTransportType.STDIO,
- command: "npx",
- args: ["mcp"],
- headers: {},
- env: {},
- },
- ],
- debug: true,
};
- const result = agentConfigSchema.parse(config);
- expect(result.name).toBe("full-agent");
- expect(result.debug).toBe(true);
+ // Act & Assert — strict schema should reject unknown key "store_backend"
+ expect(() => agentConfigSchema.parse(configWithStoreBackend)).toThrow();
+ });
+
+ it("rejects invalid checkpoint_backend value", () => {
+ // Arrange
+ const configWithInvalidCheckpoint = {
+ ...baseConfig,
+ backend: {
+ type: BackendType.STORE,
+ checkpoint_backend: "redis",
+ },
+ };
+ // Act & Assert
+ expect(() => agentConfigSchema.parse(configWithInvalidCheckpoint)).toThrow();
});
it("rejects missing required fields", () => {
@@ -220,17 +331,8 @@ describe("agentConfigSchema", () => {
it("rejects whitespace-only name", () => {
expect(() =>
agentConfigSchema.parse({
+ ...baseConfig,
name: " ",
- model: "gpt-4",
- tools: [],
- middleware: [],
- backend: { type: BackendType.STATE },
- hitl: { rules: {} },
- memory: [],
- skills: [],
- subagents: [],
- mcp_servers: [],
- debug: false,
}),
).toThrow();
});
@@ -238,17 +340,8 @@ describe("agentConfigSchema", () => {
it("rejects name over 100 characters", () => {
expect(() =>
agentConfigSchema.parse({
+ ...baseConfig,
name: "a".repeat(101),
- model: "gpt-4",
- tools: [],
- middleware: [],
- backend: { type: BackendType.STATE },
- hitl: { rules: {} },
- memory: [],
- skills: [],
- subagents: [],
- mcp_servers: [],
- debug: false,
}),
).toThrow();
});
@@ -256,54 +349,17 @@ describe("agentConfigSchema", () => {
it("rejects name with special characters", () => {
expect(() =>
agentConfigSchema.parse({
+ ...baseConfig,
name: "my agent!",
- model: "gpt-4",
- tools: [],
- middleware: [],
- backend: { type: BackendType.STATE },
- hitl: { rules: {} },
- memory: [],
- skills: [],
- subagents: [],
- mcp_servers: [],
- debug: false,
- }),
- ).toThrow();
- });
-
- it("rejects invalid middleware value", () => {
- expect(() =>
- agentConfigSchema.parse({
- name: "bad",
- model: "gpt-4",
- tools: [],
- middleware: ["invalid"],
- backend: { type: BackendType.STATE },
- hitl: { rules: {} },
- memory: [],
- skills: [],
- subagents: [],
- mcp_servers: [],
- debug: false,
}),
).toThrow();
});
it("accepts optional system_prompt_file", () => {
const result = agentConfigSchema.parse({
- name: "agent",
- model: "gpt-4",
+ ...baseConfig,
system_prompt_file: "/prompts/system.txt",
- tools: [],
- middleware: [],
- backend: { type: BackendType.STATE },
- hitl: { rules: {} },
- memory: [],
- skills: [],
- subagents: [],
- mcp_servers: [],
- debug: false,
});
expect(result.system_prompt_file).toBe("/prompts/system.txt");
});
-});
+});
\ No newline at end of file
diff --git a/tests/unit/domain/entities/storeFile.test.ts b/tests/unit/domain/entities/storeFile.test.ts
new file mode 100644
index 0000000..521cae5
--- /dev/null
+++ b/tests/unit/domain/entities/storeFile.test.ts
@@ -0,0 +1,26 @@
+import { describe, it, expect } from "vitest";
+import type { StoreFileMetadata, StoreFile } from "@/domain/entities/store/storeFile";
+
+describe("StoreFileMetadata", () => {
+ it("type exists with a path field", () => {
+ // Arrange & Act — construct a value conforming to the type
+ const metadata: StoreFileMetadata = { path: "skills/rag/SKILL.md" };
+
+ // Assert — the path field is present and assignable
+ expect(metadata.path).toBe("skills/rag/SKILL.md");
+ });
+});
+
+describe("StoreFile", () => {
+ it("type exists with path and content fields", () => {
+ // Arrange & Act — construct a value conforming to the type
+ const file: StoreFile = {
+ path: "skills/rag/SKILL.md",
+ content: "---\nname: rag\n---\n# RAG skill",
+ };
+
+ // Assert — both fields are present and assignable
+ expect(file.path).toBe("skills/rag/SKILL.md");
+ expect(file.content).toBe("---\nname: rag\n---\n# RAG skill");
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/hooks/agent/useAgentConfig.test.tsx b/tests/unit/hooks/agent/useAgentConfig.test.tsx
index 24fc0b1..81b4f93 100644
--- a/tests/unit/hooks/agent/useAgentConfig.test.tsx
+++ b/tests/unit/hooks/agent/useAgentConfig.test.tsx
@@ -3,7 +3,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { useAgentConfig } from "@/application/hooks/agent/useAgentConfig";
import { agentApi } from "@/infrastructure/api/agent/agentApi";
-import type { AgentConfig, BackendType } from "@/domain/entities/agent/agentConfig";
+import type { AgentConfig } from "@/domain/entities/agent/agentConfig";
+import { BackendType } from "@/domain/entities/agent/agentConfig";
import type { ReactNode } from "react";
vi.mock("@/infrastructure/api/agent/agentApi", () => ({
@@ -30,8 +31,7 @@ const mockAgentConfig: AgentConfig = {
model: "openai:gpt-4o",
system_prompt: "You are a helpful assistant.",
tools: ["search", "calculator"],
- middleware: [],
- backend: { type: "state" as BackendType },
+ backend: { type: BackendType.STORE, checkpoint_backend: "memory" },
hitl: { rules: {} },
memory: [],
skills: [],
diff --git a/tests/unit/hooks/agent/useCreateAgent.test.tsx b/tests/unit/hooks/agent/useCreateAgent.test.tsx
index c5663bd..1a2e4a1 100644
--- a/tests/unit/hooks/agent/useCreateAgent.test.tsx
+++ b/tests/unit/hooks/agent/useCreateAgent.test.tsx
@@ -3,7 +3,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { useCreateAgent } from "@/application/hooks/agent/useCreateAgent";
import { agentApi } from "@/infrastructure/api/agent/agentApi";
-import type { AgentConfig, BackendType } from "@/domain/entities/agent/agentConfig";
+import type { AgentConfig } from "@/domain/entities/agent/agentConfig";
+import { BackendType } from "@/domain/entities/agent/agentConfig";
import type { ReactNode } from "react";
vi.mock("@/infrastructure/api/agent/agentApi", () => ({
@@ -35,8 +36,7 @@ const mockCreatedConfig: AgentConfig = {
name: "new-agent",
model: "openai:gpt-4o",
tools: [],
- middleware: [],
- backend: { type: "state" as BackendType },
+ backend: { type: BackendType.STORE, checkpoint_backend: "memory" },
hitl: { rules: {} },
memory: [],
skills: [],
diff --git a/tests/unit/hooks/agent/useUpdateAgent.test.tsx b/tests/unit/hooks/agent/useUpdateAgent.test.tsx
index 0506e81..e10ba55 100644
--- a/tests/unit/hooks/agent/useUpdateAgent.test.tsx
+++ b/tests/unit/hooks/agent/useUpdateAgent.test.tsx
@@ -3,7 +3,8 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { vi, describe, it, expect, beforeEach } from "vitest";
import { useUpdateAgent } from "@/application/hooks/agent/useUpdateAgent";
import { agentApi } from "@/infrastructure/api/agent/agentApi";
-import type { AgentConfig, BackendType } from "@/domain/entities/agent/agentConfig";
+import type { AgentConfig } from "@/domain/entities/agent/agentConfig";
+import { BackendType } from "@/domain/entities/agent/agentConfig";
import type { ReactNode } from "react";
vi.mock("@/infrastructure/api/agent/agentApi", () => ({
@@ -35,8 +36,7 @@ const mockUpdatedConfig: AgentConfig = {
name: "my-agent",
model: "openai:gpt-4o",
tools: [],
- middleware: [],
- backend: { type: "state" as BackendType },
+ backend: { type: BackendType.STORE, checkpoint_backend: "memory" },
hitl: { rules: {} },
memory: [],
skills: [],
diff --git a/tests/unit/hooks/memory/useCreateMemory.test.tsx b/tests/unit/hooks/memory/useCreateMemory.test.tsx
new file mode 100644
index 0000000..44acbf9
--- /dev/null
+++ b/tests/unit/hooks/memory/useCreateMemory.test.tsx
@@ -0,0 +1,98 @@
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useCreateMemory } from "@/application/hooks/memory/useCreateMemory";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/store/storeApi", () => ({
+ storeApi: {
+ listFiles: vi.fn(),
+ getFile: vi.fn(),
+ putFile: vi.fn(),
+ deleteFile: vi.fn(),
+ },
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+ return {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ queryClient,
+ };
+}
+
+describe("useCreateMemory", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates a memory when no collision", async () => {
+ // Arrange — getFile returns null (file does not exist)
+ vi.mocked(storeApi.getFile).mockResolvedValue(null);
+ vi.mocked(storeApi.putFile).mockResolvedValue(undefined);
+ const { wrapper } = createWrapper();
+
+ // Act
+ const { result } = renderHook(() => useCreateMemory(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ name: "AGENTS", content: "# Rules" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert
+ expect(storeApi.getFile).toHaveBeenCalledWith("/memories/AGENTS.md");
+ expect(storeApi.putFile).toHaveBeenCalledWith("/memories/AGENTS.md", "# Rules");
+ });
+
+ it("blocks creation when a memory with the same name already exists", async () => {
+ // Arrange — getFile returns an existing file (collision)
+ vi.mocked(storeApi.getFile).mockResolvedValue({
+ path: "/memories/AGENTS.md",
+ content: "# Existing rules",
+ });
+ const { wrapper } = createWrapper();
+
+ // Act
+ const { result } = renderHook(() => useCreateMemory(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ name: "AGENTS", content: "# New rules" });
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ // Assert — putFile must NOT be called, error must mention the collision
+ expect(storeApi.putFile).not.toHaveBeenCalled();
+ expect(result.current.error?.message).toContain('A memory named "AGENTS" already exists');
+ });
+
+ it("invalidates ['store-files'] on success", async () => {
+ // Arrange
+ vi.mocked(storeApi.getFile).mockResolvedValue(null);
+ vi.mocked(storeApi.putFile).mockResolvedValue(undefined);
+ const { wrapper, queryClient } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ // Act
+ const { result } = renderHook(() => useCreateMemory(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ name: "AGENTS", content: "# Rules" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["store-files"] });
+ });
+});
diff --git a/tests/unit/hooks/skill/useCreateSkill.test.tsx b/tests/unit/hooks/skill/useCreateSkill.test.tsx
new file mode 100644
index 0000000..bc9743f
--- /dev/null
+++ b/tests/unit/hooks/skill/useCreateSkill.test.tsx
@@ -0,0 +1,101 @@
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useCreateSkill } from "@/application/hooks/skill/useCreateSkill";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/store/storeApi", () => ({
+ storeApi: {
+ listFiles: vi.fn(),
+ getFile: vi.fn(),
+ putFile: vi.fn(),
+ deleteFile: vi.fn(),
+ },
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+ return {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ queryClient,
+ };
+}
+
+describe("useCreateSkill", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("creates a skill when no collision", async () => {
+ // Arrange — getFile returns null (file does not exist)
+ vi.mocked(storeApi.getFile).mockResolvedValue(null);
+ vi.mocked(storeApi.putFile).mockResolvedValue(undefined);
+ const { wrapper } = createWrapper();
+
+ // Act
+ const { result } = renderHook(() => useCreateSkill(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ name: "rag", description: "RAG queries", content: "# RAG" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert
+ expect(storeApi.getFile).toHaveBeenCalledWith("/skills/rag/SKILL.md");
+ expect(storeApi.putFile).toHaveBeenCalledWith(
+ "/skills/rag/SKILL.md",
+ expect.stringContaining("name: rag"),
+ );
+ });
+
+ it("blocks creation when a skill with the same name already exists", async () => {
+ // Arrange — getFile returns an existing file (collision)
+ vi.mocked(storeApi.getFile).mockResolvedValue({
+ path: "/skills/rag/SKILL.md",
+ content: "---\nname: rag\n---\n# Existing",
+ });
+ const { wrapper } = createWrapper();
+
+ // Act
+ const { result } = renderHook(() => useCreateSkill(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ name: "rag", description: "New description", content: "# New" });
+ });
+
+ await waitFor(() => expect(result.current.isError).toBe(true));
+
+ // Assert — putFile must NOT be called, error must mention the collision
+ expect(storeApi.putFile).not.toHaveBeenCalled();
+ expect(result.current.error?.message).toContain('A skill named "rag" already exists');
+ });
+
+ it("invalidates ['store-files'] on success", async () => {
+ // Arrange
+ vi.mocked(storeApi.getFile).mockResolvedValue(null);
+ vi.mocked(storeApi.putFile).mockResolvedValue(undefined);
+ const { wrapper, queryClient } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ // Act
+ const { result } = renderHook(() => useCreateSkill(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ name: "rag", description: "RAG", content: "# RAG" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["store-files"] });
+ });
+});
diff --git a/tests/unit/hooks/store/useDeleteStoreFile.test.tsx b/tests/unit/hooks/store/useDeleteStoreFile.test.tsx
new file mode 100644
index 0000000..8b7ff45
--- /dev/null
+++ b/tests/unit/hooks/store/useDeleteStoreFile.test.tsx
@@ -0,0 +1,73 @@
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useDeleteStoreFile } from "@/application/hooks/store/useDeleteStoreFile";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/store/storeApi", () => ({
+ storeApi: {
+ listFiles: vi.fn(),
+ getFile: vi.fn(),
+ putFile: vi.fn(),
+ deleteFile: vi.fn(),
+ },
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+ return {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ queryClient,
+ };
+}
+
+describe("useDeleteStoreFile", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("calls storeApi.deleteFile with path", async () => {
+ // Arrange
+ vi.mocked(storeApi.deleteFile).mockResolvedValue(undefined);
+ const { wrapper } = createWrapper();
+
+ // Act
+ const { result } = renderHook(() => useDeleteStoreFile(), { wrapper });
+
+ act(() => {
+ result.current.mutate("skills/rag/SKILL.md");
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert
+ expect(storeApi.deleteFile).toHaveBeenCalledWith("skills/rag/SKILL.md");
+ });
+
+ it("invalidates ['store-files'] on success", async () => {
+ // Arrange
+ vi.mocked(storeApi.deleteFile).mockResolvedValue(undefined);
+ const { wrapper, queryClient } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ // Act
+ const { result } = renderHook(() => useDeleteStoreFile(), { wrapper });
+
+ act(() => {
+ result.current.mutate("skills/rag/SKILL.md");
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["store-files"] });
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/hooks/store/usePutStoreFile.test.tsx b/tests/unit/hooks/store/usePutStoreFile.test.tsx
new file mode 100644
index 0000000..1e05acd
--- /dev/null
+++ b/tests/unit/hooks/store/usePutStoreFile.test.tsx
@@ -0,0 +1,73 @@
+import { renderHook, waitFor, act } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { usePutStoreFile } from "@/application/hooks/store/usePutStoreFile";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/store/storeApi", () => ({
+ storeApi: {
+ listFiles: vi.fn(),
+ getFile: vi.fn(),
+ putFile: vi.fn(),
+ deleteFile: vi.fn(),
+ },
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: {
+ queries: { retry: false, gcTime: 0 },
+ mutations: { retry: false },
+ },
+ });
+ return {
+ wrapper: ({ children }: { children: ReactNode }) => (
+ {children}
+ ),
+ queryClient,
+ };
+}
+
+describe("usePutStoreFile", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("calls storeApi.putFile with path and content", async () => {
+ // Arrange
+ vi.mocked(storeApi.putFile).mockResolvedValue(undefined);
+ const { wrapper } = createWrapper();
+
+ // Act
+ const { result } = renderHook(() => usePutStoreFile(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ path: "skills/rag/SKILL.md", content: "# RAG" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert
+ expect(storeApi.putFile).toHaveBeenCalledWith("skills/rag/SKILL.md", "# RAG");
+ });
+
+ it("invalidates ['store-files'] on success", async () => {
+ // Arrange
+ vi.mocked(storeApi.putFile).mockResolvedValue(undefined);
+ const { wrapper, queryClient } = createWrapper();
+ const invalidateSpy = vi.spyOn(queryClient, "invalidateQueries");
+
+ // Act
+ const { result } = renderHook(() => usePutStoreFile(), { wrapper });
+
+ act(() => {
+ result.current.mutate({ path: "skills/rag/SKILL.md", content: "# RAG" });
+ });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert — any query whose key starts with "store-files" must be invalidated
+ expect(invalidateSpy).toHaveBeenCalledWith({ queryKey: ["store-files"] });
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/hooks/store/useStoreFile.test.tsx b/tests/unit/hooks/store/useStoreFile.test.tsx
new file mode 100644
index 0000000..700fa14
--- /dev/null
+++ b/tests/unit/hooks/store/useStoreFile.test.tsx
@@ -0,0 +1,96 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useStoreFile } from "@/application/hooks/store/useStoreFile";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+import type { StoreFile } from "@/domain/entities/store/storeFile";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/store/storeApi", () => ({
+ storeApi: {
+ listFiles: vi.fn(),
+ getFile: vi.fn(),
+ putFile: vi.fn(),
+ deleteFile: vi.fn(),
+ },
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ return ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+}
+
+describe("useStoreFile", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns StoreFile when data loads", async () => {
+ // Arrange
+ const file: StoreFile = {
+ path: "skills/rag/SKILL.md",
+ content: "---\nname: rag\n---\n# RAG",
+ };
+ vi.mocked(storeApi.getFile).mockResolvedValue(file);
+
+ // Act
+ const { result } = renderHook(
+ () => useStoreFile("skills/rag/SKILL.md"),
+ { wrapper: createWrapper() },
+ );
+
+ // Assert
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual(file);
+ expect(storeApi.getFile).toHaveBeenCalledWith("skills/rag/SKILL.md");
+ });
+
+ it("returns null when file not found", async () => {
+ // Arrange — API resolves null for a missing file
+ vi.mocked(storeApi.getFile).mockResolvedValue(null);
+
+ // Act
+ const { result } = renderHook(
+ () => useStoreFile("skills/missing/SKILL.md"),
+ { wrapper: createWrapper() },
+ );
+
+ // Assert
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toBeNull();
+ });
+
+ it("uses query key ['store-file', path]", async () => {
+ // Arrange
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ const file: StoreFile = {
+ path: "skills/rag/SKILL.md",
+ content: "# RAG",
+ };
+ vi.mocked(storeApi.getFile).mockResolvedValue(file);
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ // Act
+ const { result } = renderHook(
+ () => useStoreFile("skills/rag/SKILL.md"),
+ { wrapper },
+ );
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert — the cached data must be retrievable under the exact query key
+ const cached = queryClient.getQueryData([
+ "store-file",
+ "skills/rag/SKILL.md",
+ ]);
+ expect(cached).toEqual(file);
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/hooks/store/useStoreFiles.test.tsx b/tests/unit/hooks/store/useStoreFiles.test.tsx
new file mode 100644
index 0000000..2e76051
--- /dev/null
+++ b/tests/unit/hooks/store/useStoreFiles.test.tsx
@@ -0,0 +1,90 @@
+import { renderHook, waitFor } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { vi, describe, it, expect, beforeEach } from "vitest";
+import { useStoreFiles } from "@/application/hooks/store/useStoreFiles";
+import { storeApi } from "@/infrastructure/api/store/storeApi";
+import type { StoreFileMetadata } from "@/domain/entities/store/storeFile";
+import type { ReactNode } from "react";
+
+vi.mock("@/infrastructure/api/store/storeApi", () => ({
+ storeApi: {
+ listFiles: vi.fn(),
+ getFile: vi.fn(),
+ putFile: vi.fn(),
+ deleteFile: vi.fn(),
+ },
+}));
+
+function createWrapper() {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ return ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+}
+
+describe("useStoreFiles", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("returns list of StoreFileMetadata when data loads", async () => {
+ // Arrange
+ const files: StoreFileMetadata[] = [
+ { path: "skills/rag/SKILL.md" },
+ { path: "skills/web-search/SKILL.md" },
+ ];
+ vi.mocked(storeApi.listFiles).mockResolvedValue(files);
+
+ // Act
+ const { result } = renderHook(() => useStoreFiles("skills/"), {
+ wrapper: createWrapper(),
+ });
+
+ // Assert
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual(files);
+ expect(storeApi.listFiles).toHaveBeenCalledWith("skills/");
+ });
+
+ it("returns empty array when no files", async () => {
+ // Arrange
+ vi.mocked(storeApi.listFiles).mockResolvedValue([]);
+
+ // Act
+ const { result } = renderHook(() => useStoreFiles("skills/"), {
+ wrapper: createWrapper(),
+ });
+
+ // Assert
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+ expect(result.current.data).toEqual([]);
+ });
+
+ it("uses query key ['store-files', prefix]", async () => {
+ // Arrange — spy on the query client to observe the query key used
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ const getQueryDataSpy = vi.spyOn(queryClient, "getQueryData");
+ vi.mocked(storeApi.listFiles).mockResolvedValue([]);
+ const wrapper = ({ children }: { children: ReactNode }) => (
+ {children}
+ );
+
+ // Act
+ const { result } = renderHook(() => useStoreFiles("memories/"), { wrapper });
+
+ await waitFor(() => expect(result.current.isSuccess).toBe(true));
+
+ // Assert — the query key registered for this prefix must include "store-files" and the prefix
+ getQueryDataSpy.mockRestore();
+ // The presence of data under the exact key is verified by fetching it back
+ const cached = queryClient.getQueryData([
+ "store-files",
+ "memories/",
+ ]);
+ expect(cached).toEqual([]);
+ });
+});
\ No newline at end of file
diff --git a/tests/unit/lib/frontmatter.test.ts b/tests/unit/lib/frontmatter.test.ts
new file mode 100644
index 0000000..74a10c1
--- /dev/null
+++ b/tests/unit/lib/frontmatter.test.ts
@@ -0,0 +1,100 @@
+import { describe, it, expect } from "vitest";
+import { parseFrontmatter, buildFrontmatter } from "@/application/lib/frontmatter";
+
+describe("parseFrontmatter", () => {
+ it("extracts name and description from YAML frontmatter", () => {
+ // Arrange
+ const input = "---\nname: rag\ndescription: RAG queries\n---\n# Body";
+
+ // Act
+ const result = parseFrontmatter(input);
+
+ // Assert
+ expect(result.data).toEqual({ name: "rag", description: "RAG queries" });
+ expect(result.body).toBe("# Body");
+ });
+
+ it("returns empty data and full body when no frontmatter", () => {
+ // Arrange
+ const input = "no frontmatter";
+
+ // Act
+ const result = parseFrontmatter(input);
+
+ // Assert
+ expect(result.data).toEqual({});
+ expect(result.body).toBe("no frontmatter");
+ });
+
+ it("handles multi-line frontmatter", () => {
+ // Arrange — frontmatter with several keys
+ const input =
+ "---\nname: research\ndescription: Research agent\nauthor: team\n---\n# Research skill";
+
+ // Act
+ const result = parseFrontmatter(input);
+
+ // Assert
+ expect(result.data).toEqual({
+ name: "research",
+ description: "Research agent",
+ author: "team",
+ });
+ expect(result.body).toBe("# Research skill");
+ });
+
+ it("handles special characters in description", () => {
+ // Arrange — description with punctuation and accents
+ const input =
+ "---\nname: rag\ndescription: RAG: \"queries & accents\" — éàç\n---\n# Body";
+
+ // Act
+ const result = parseFrontmatter(input);
+
+ // Assert
+ expect(result.data).toHaveProperty("name", "rag");
+ expect(result.data).toHaveProperty("description", 'RAG: "queries & accents" — éàç');
+ expect(result.body).toBe("# Body");
+ });
+
+ it("does NOT truncate body containing a horizontal rule", () => {
+ // Arrange — body contains a `---` line (markdown horizontal rule) after
+ // the frontmatter. The closing delimiter regex with the `m` flag would
+ // incorrectly match this line and truncate the body.
+ const input =
+ "---\nname: rag\ndescription: RAG queries\n---\n# Title\n---\nsome content after hr";
+
+ // Act
+ const result = parseFrontmatter(input);
+
+ // Assert — the body must include everything after the frontmatter,
+ // including the horizontal rule line and the content after it.
+ expect(result.data).toEqual({ name: "rag", description: "RAG queries" });
+ expect(result.body).toBe("# Title\n---\nsome content after hr");
+ });
+});
+
+describe("buildFrontmatter", () => {
+ it("builds valid YAML frontmatter with name and description", () => {
+ // Arrange
+ const data = { name: "rag", description: "RAG" };
+
+ // Act
+ const output = buildFrontmatter(data, "");
+
+ // Assert
+ expect(output).toBe("---\nname: rag\ndescription: RAG\n---\n");
+ });
+
+ it("appends body after frontmatter", () => {
+ // Arrange
+ const data = { name: "rag", description: "RAG" };
+ const body = "# Body";
+
+ // Act
+ const output = buildFrontmatter(data, body);
+
+ // Assert
+ expect(output).toBe("---\nname: rag\ndescription: RAG\n---\n# Body");
+ });
+});
\ No newline at end of file